diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index e38cef3..afd5780 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -32,18 +32,27 @@ The source tree has one desktop presentation implementation: - the QML frontend uses the authoritative QtCore controllers and private worker boundary; - the QML application currently implements the responsive shell, Workspace, - Dataset, Visualization, YAML Preview, Run, Inspect, Settings, and Help surfaces, - including worker-validated Save and Save As plus exact-saved-configuration - validation and generation; + Dataset, Model Sweep, Visualization, YAML Preview, Run, Inspect, Activity, + Settings, and Help surfaces, including one global worker-validated + configuration lifecycle for Dataset, Model Sweep, and Preparation YAML; +- the structured Preparation page and scenario editor are packaged, tested, + and enabled through the Stage 5 Unit 19B shell integration, including the + explicit inspected-source prerequisite for new documents; - typed blocking state, revision-bound standalone validation, operation feedback, exact Dataset row projections, and composition-owned document and shutdown decisions are shared with the authoritative controllers rather than reimplemented in QML; -- saved-config validation and generation state are owned by one +- Dataset saved-config validation and generation state are owned by one `DatasetExecutionController`; the public QML Run workflow is its view; +- revision-bound Sweep and Preparation planning, execution, cancellation, + protected finalization, Activity persistence, and finalized-result identity + are owned by focused workflow controllers rather than the configuration + controller or QML pages; - source discovery, worker inspection, typed source summaries, logical-array - metadata, table selection, and bounded preview state are owned by one - `InspectionController`; the public QML Inspect workbench is its view; + metadata, integrity-verified Preparation quality flags, table selection, and + bounded preview state are owned by one `InspectionController`; the public QML + Inspect workbench presents those projections together with typed finalized + Preparation audit evidence; - private Run-activity loading, typed projection, record-only removal, interrupted-state projection, and identity-checked staging recovery are owned by one `ActivityController`; the public QML Activity page is its view; @@ -124,11 +133,14 @@ not a second scientific implementation. | +------------------+------------------+ | | | - WorkspaceController DatasetConfigController QmlSettingsController + WorkspaceController ConfigurationController QmlSettingsController | | - | DatasetDraft + VisualizationDraft + | DatasetDraft + VisualizationDraft + | SweepDraft + PreparationDraft | | +------- DatasetExecutionController + +------- SweepWorkflowController + +------- PreparationWorkflowController | +------------ InspectionController | | @@ -163,9 +175,12 @@ cross-controller facade. One instance owns: - `WorkspaceController`; - `DatasetDraft`; - `VisualizationDraft`; -- `DatasetConfigController`; +- `ConfigurationController`, including its `SweepDraft` and + `PreparationDraft`; - `DatasetExecutionController`; - `InspectionController`; +- `SweepWorkflowController`; +- `PreparationWorkflowController`; - `ActivityController`; - `ConfiguredPlotResultsController`; - `SessionPlotController`; and @@ -184,6 +199,18 @@ explicit inspection of the requested source succeeds; the view never infers a source, performs hidden inspection, or starts rendering as a navigation side effect. +QML enablement is never the lifecycle authority. Every global configuration +lifecycle and worker-start slot on `DesktopController` rechecks transient +editors and global request idleness before delegating. Direct Plan and Execute +requests therefore cannot bypass an open comparison or scenario editor. +Document editing uses one operation-aware Python policy across Dataset, Sweep, +Preparation, and configured Visualization: configuration load, validation, +Save, and workflow planning lock the owning document, while execution permits +ordinary in-memory edits beside its captured immutable snapshot. Owned nested +objects are rechecked at each slot boundary, and a session plot cannot be +mutated while its render worker owns the submitted request. Cancellation and +Force Stop retain their separate active-operation paths. + ### `DesktopRequestCoordinator` The coordinator owns the global active request, including: @@ -201,6 +228,21 @@ cancellation becomes available only when the worker reports a cancellable phase. Force stop is explicit and remains distinguishable in the terminal envelope. +A matching request UUID is necessary but not sufficient for a controller to +adopt a terminal response. Each response-owning controller retains only its +operation-specific semantic context. Configuration requests retain workspace +and document-generation identity plus the applicable document kind, exact YAML +hash, and requested source. Inspection requests retain workspace, source, +inspection revision, table, worker-block offset, and local-page offset. +Workflow requests retain workspace and operation plus the requested source, +captured saved snapshot, and immutable execution plan/source context. A late +success or failure whose context no longer matches is discarded without +replacing newer state. Returned configuration sources and preview block +offsets are also checked against the request. This is deliberately local to the +three owners: the single coordinator remains authoritative, and navigation or +ordinary edits made beside an immutable active execution snapshot do not create +a second identity system or invalidate that execution response. + Execution startup has an additional private reservation boundary. Reserving a UUID prevents another request from starting but does not advertise global busy state. `DatasetExecutionController` atomically writes the initial Run-activity @@ -243,6 +285,35 @@ a result historical; a later Save, file replacement, document replacement, or workspace change can. This distinction prevents mutable editor state from rewriting the identity of an already executed scientific configuration. +### Sweep and Preparation workflow controllers + +`SweepWorkflowController` and `PreparationWorkflowController` consume exact +`SavedConfigSnapshot` values from `ConfigurationController`; they do not own a +second configuration document. Each owns its last accepted plan, active +execution attempt, progress and cancellation state, Activity writes, protected +finalization state, and last finalized result. Typed issue models distinguish +planning and execution blockers, while `planCurrent` and `resultRelation` +compare the exact saved configuration SHA-256 and workflow context. Finalized +results remain inspectable as `current`, `stale`, or `unrelated` when the +global document changes, and a later failed or cancelled attempt does not erase +the prior finalized result. + +Planning accepts only a clean saved snapshot of the expected document type. +Execution retains that immutable snapshot and plan context even when ordinary +in-memory editing is allowed during the worker run. The worker recomputes and +verifies the plan before writing. Navigation and QML page lifetime never own or +erase scientific workflow state. + +`PreparationWorkflowController` additionally owns one private immutable source +binding containing the resolved source path, inspection revision, accepted +descriptor, and typed preparation profile. Binding copies an explicitly +selected successful inspection; merely inspecting another artifact does not +replace it. Rebinding or clearing is explicit, changes execution context +without dirtying Preparation YAML, and stales a plan only when the semantic +binding differs. Workspace replacement clears the binding, while document +replacement and normal Inspect navigation do not. Planning and execution still +revalidate the bound descriptor and revision in the worker. + ### `InspectionController` `InspectionController` is the authoritative read-only source-inspection @@ -253,11 +324,20 @@ workflow. It owns: - the active worker inspection request and exact source revision; - source-kind-aware identity, backend, row, diagnostic, table, and integrity projections; +- a private preparation-eligibility descriptor and typed source profile for an + explicitly inspected immutable dataset run or model-sweep bundle; - dataset row totals together with the worker-reported column count, without opening the table again in the GUI process; - three independent dataset failure aggregates for layer, code, and property; - one typed row per logical array, including distinct shapes and dtypes within a shared artifact; +- an integrity-verified `quality_flags` table for current Preparation bundles, + omitted from table control when its optional artifact is missing or corrupt + while the worker-reported quality error keeps the main bundle inspectable; +- one focused Preparation audit-state object containing fixed typed models for + quality overview, scenarios, partitions, leakage, duplicate-state and grid + evidence, matrix diagnostics, correlations, singular values, and baseline + metrics or failures; - selected table identity, 500-row worker blocks, and 100-row local pages; and - the copied inspected plot context consumed by `SessionPlotController`. @@ -282,6 +362,36 @@ preview is queued after an explicit successful inspection because the request coordinator releases its active session only after delivering the terminal result. +For current Preparation bundles, private scenario audit evidence is read only +from the canonical contained `data/scenarios//scenario.json` path after +its recorded hash is verified. The exact bytes must agree with the finalized +scenario name, kind, and partition counts before leakage fields enter the +worker payload. Each resolved scenario audit file identity contributes to the +inspection revision. Missing legacy evidence remains unavailable rather than +being inferred. The controller validates the complete detached audit +projection before replacing any audit models, rejects audit payloads on other +source kinds, and clears those models when inspection becomes stale. + +`PreparationAuditView` is the reusable typed presentation boundary for those +models. It does not own inspection state, read raw manifest dictionaries, or +request worker operations. Its quality/scenario, matrix, and baseline sections +use bounded reusable list delegates, preserve the diagnostic context carried by +each fixed role contract, and distinguish unavailable evidence from an empty +recorded result. Unit 21A packages and directly tests the component in +populated, unavailable, wide, and narrow states. Unit 21B integrates it as a +Preparation-only Inspect tab, retains explicit legacy-unavailable and +artifact-issue states, and returns to Summary whenever a selected audit tab no +longer matches the accepted source kind. + +The preparation profile projects source kind and revision, available models, +eligible numeric, target, categorical, and auxiliary fields, observed category +values, curated derived-feature readiness, partial-sweep state, reference +contexts, and model-holdout availability. It is derived from verified metadata +and the established preparation field-resolution code in the worker. QML +consumes typed Qt models and never reconstructs preparation semantics from raw +manifest dictionaries. The profile remains an inspection result until the +user explicitly binds its exact snapshot for Preparation. + ### `ActivityController` `ActivityController` is the authoritative read side of private Run activity and @@ -472,28 +582,40 @@ composition layer runs lifecycle guards before preflight and again before commit. Recent workspaces are canonical paths stored under the stable Carnopy application identity. -## Dataset configuration ownership +## Global configuration ownership -`DatasetConfigController` owns the complete document workflow, not a Widget or -QML page. It coordinates: +`ConfigurationController` owns one globally active Dataset, Model Sweep, or +Preparation document, not a Widget or QML page. It coordinates: - workspace context and capability discovery; -- New, Import, document replacement, and close; -- deterministic merging of `DatasetDraft` and `VisualizationDraft`; +- kind-specific New, generic Import, document replacement, and close; +- deterministic composition through `DatasetDraft` and `VisualizationDraft`, + `SweepDraft`, or `PreparationDraft`; - local validity, dirty state, and YAML preview; - worker-authoritative import and exact-YAML Save validation; - Save versus Save As, imported-reformat confirmation, and external-change protection; -- atomic workspace-owned replacement and no-overwrite new writes; and -- saved execution snapshots. - -`DatasetConfigDocument` retains the complete YAML payload independently of the -structured drafts. This preserves unknown or non-edited document sections and -allows the controller to merge authoritative draft sections into the complete -document rather than reconstructing a partial file. +- atomic workspace-owned replacement and exclusive no-overwrite new writes; + and +- typed saved execution snapshots. + +`ConfigurationDocument` retains the complete typed YAML payload independently +of the structured drafts. Its deterministic serializers preserve the complete +current public Dataset, Model Sweep, and Preparation shapes, including ordered +comparison plots, scenarios, and transformations. `SavedConfigSnapshot` +records the exact workspace-owned path, saved bytes, SHA-256, and document +type, and consumers must request the expected type. + +Generic worker import reads one YAML mapping and dispatches directly from the +required, mutually exclusive `document_type` literal. It never determines +meaning by trying the three public schemas in sequence. Imported exact bytes +retain their source hash and do not silently become normalized saved bytes; +the read-only preview shows the deterministic in-memory serialization while +`reformatRequired` keeps the ownership distinction explicit until an accepted +Save. Workspace initialization itself does not load a backend. After activation, -`DatasetConfigController` prepares the configuration editor with a local +`ConfigurationController` prepares the configuration editor with a local `describe_capabilities` worker request. For the current single-backend milestone, that worker imports the installed CoolProp package, enumerates fluid names and aliases, and builds the supported model, property, and visualization @@ -502,32 +624,34 @@ of the application process. If Carnopy later approves another backend, the capability request and cache identity must become explicitly backend/model- aware; the current CoolProp-only path is not a general plugin architecture. -The document is updated only from locally valid dataset and visualization -state. A successful worker validation and file write refreshes baselines; -failed validation or writing does not declare the draft saved. +The document is updated only from the locally valid draft for its active kind. +A successful worker validation and file write refreshes the document and draft +baselines; failed validation or writing does not declare the draft saved. -The controller projects YAML availability and its first blocker as typed state: -`yamlAvailable`, `blockingSection`, `blockingField`, `blockingRow`, and -`blockingIssue`. Invalid draft state always exposes an empty YAML preview; the -last valid serialization and a best-effort replacement are never presented as -current. Stable field and row identifiers drive QML navigation without parsing -issue prose. +The controller projects `documentKind`, `reformatRequired`, YAML availability, +and its first blocker as typed state: `yamlAvailable`, `blockingSection`, +`blockingField`, `blockingRow`, and `blockingIssue`. Invalid draft state always +exposes an empty YAML preview; the last valid serialization and a best-effort +replacement are never presented as current. Stable field and row identifiers +drive QML navigation without parsing issue prose. Save and Save As submit the exact visible complete-document YAML to the worker before any write. Imported-document reformat consent, external-change choices, -no-overwrite Save As, atomic verified replacement, in-flight mutation checks, -and baseline refresh retain the established controller and document ownership. -Typed `operationFailed`, `saveSucceeded`, and `importSucceeded` signals provide -QML feedback. +in-flight mutation checks, and baseline refresh retain the established Dataset +contract for every document kind. Save atomically replaces only the owned file +after both source-hash checks. Save As uses exclusive creation and refuses a +destination that already exists or appears before promotion. Typed +`operationFailed`, `saveSucceeded`, and `importSucceeded` signals provide QML +feedback. Standalone worker validation is transient and revision-bound. The controller -captures the exact visible YAML bytes and SHA-256 and reports `unavailable`, -`blocked`, `not_run`, `running`, `valid`, `invalid`, `failed`, or `stale`. -Edits invalidate the relationship to any prior or in-flight result, and late -results for other bytes are ignored. A `config`/`invalid_config` response is -invalid even when its detailed issue list is empty. This state never authorizes -Save: every Save and Save As starts fresh worker validation of the exact bytes -immediately before writing. +captures the document type, exact visible YAML bytes, and SHA-256 and reports +`unavailable`, `blocked`, `not_run`, `running`, `valid`, `invalid`, `failed`, or +`stale`. Edits invalidate the relationship to any prior or in-flight result, +and late results for other bytes are ignored. A `config`/`invalid_config` +response is invalid even when its detailed issue list is empty. This state +never authorizes Save: every Save and Save As starts fresh worker validation +of the exact typed bytes immediately before writing. ### `DatasetDraft` and `SamplerDraft` @@ -583,6 +707,46 @@ text for scientific subscripts; user input is never interpreted as markup. These roles do not alter YAML property names, generated columns, metadata, or worker behavior. +### `SweepDraft` and `ComparisonPlotDraft` + +`SweepDraft` owns the complete current Model Sweep configuration shape: +ordered models and reference model, dataset mode, fluids, samplers, properties, +dataset formats, comparison format, ordered comparison snapshots, +compatibility, validity, and deterministic dirty baselines. It reuses the +lightweight sampler and capability projections; it does not call a backend or +materialize sample grids in the GUI process. Imported selections that are +currently incompatible remain visible and blocking rather than being silently +repaired. + +Committed comparison plots are immutable payload snapshots. Exactly one +Python-owned `ComparisonPlotDraft` may be active for Add or Edit, and Commit or +Cancel is explicit. While it exists, Save, validation, planning, execution, +document or workspace replacement, and mutation of the committed comparison +list are guarded in Python. Navigation may hide the page without destroying +the draft. Stable field identifiers and committed row positions support typed +focus without parsing issue text. + +### `PreparationDraft` and `ScenarioDraft` + +`PreparationDraft` owns the complete current Preparation configuration shape: +numeric and curated derived features, observed or explicit categoricals, +targets, auxiliary fields, partial-sweep policy, outputs, array formats and +dtype, matrix diagnostics, optional baseline diagnostics, and ordered scenario +snapshots. Applying or clearing a bound source profile updates capability and +compatibility projections only; it never rewrites selected YAML state or marks +the document dirty. Imported requests for unavailable optional functionality +remain present with blocking guidance. + +Committed scenarios are immutable snapshots covering all eight public kinds +and their partitions, holdouts, strata, numeric bins, and ordered +transformations. One Python-owned `ScenarioDraft` may be active. Kind changes +that would discard incompatible temporary values require an explicit decision, +and Commit or Cancel remains deliberate. The same composition-owned transient +edit guards used for configured plots and comparisons prevent visible but +uncommitted scenario state from entering Save, validation, Plan, Execute, +replacement, or shutdown. The visible Preparation page is a view of these +authoritative objects; correctness does not depend on QML component lifetime. + ### `VisualizationDraft` and `PlotDraft` `VisualizationDraft` owns configured-visualization enabled state, shared @@ -616,11 +780,11 @@ Inspect remains the source-selection and table/diagnostic workbench. The QML YAML Preview page is a read-only projection of the complete document. It provides line numbers, search, selection/copy, file and dirty-state context, -and typed navigation to the first blocking Dataset or Visualization field. It -does not edit YAML or retain stale text. Command-bar New, Import, Save, Save As, -and Close actions cross the root runtime bridge into `DesktopController`; QML -owns only the consequential decision dialogs and native file selection, not the -underlying workflow. +and typed navigation to the first blocking Dataset, Visualization, Sweep, or +Preparation field. It does not edit YAML or retain stale text. Command-bar New, +generic Open/Import, Save, Save As, and Close actions cross the root runtime +bridge into `DesktopController`; QML owns only the consequential decision +dialogs and native file selection, not the underlying workflow. ## Public QML frontend @@ -670,6 +834,20 @@ close. Configuration, inspection, and preview operations without a safe cancellation path remain wait-only. These decisions are enforced in `DesktopController`; QML presents the decision and cannot bypass it. +Every consequential close confirmation is bound to the state its dialog +described. Busy-close consent retains the exact `RequestSession`; delayed +cooperative cancellation cannot target a replacement request. Protected +finalization is wait-only and resumes close processing only after coordinator +release. Transient-edit consent retains the exact configured plot, session +plot, Sweep comparison, and Preparation scenario identities, while dirty-close +consent retains the configuration object and its controller-state revision. A +stale confirmation is rejected rather than applied to replacement state. +Worker completion then advances through explicit transient-edit cancellation +and dirty-document discard in that order, and the final native close request is +emitted only after every remaining guard passes. QML labels cooperative Sweep +and Preparation shutdown as cancellation; force-stop wording remains exclusive +to the eligible session-render path. + The QML Run page follows that same queued root-signal boundary for Validate, Generate, Cancel, and Force Stop. It projects the authoritative execution controller's exact saved snapshot, progress, terminal result, activity @@ -679,6 +857,40 @@ output directory through the inspection controller. **View Plots** selects its exact generation request in configured results, including the explicit empty state when no configured report exists. Neither action renders automatically. +The enabled QML Model Sweep page edits the complete current sweep schema +through `SweepDraft` and the one temporary `ComparisonPlotDraft`. A shared +workflow run panel projects typed blockers, plan evidence, progress, +cancellation, protected finalization, Activity persistence, and current, +stale, or unrelated result state from `SweepWorkflowController`. Opening the +page or editing the draft starts no worker. Plan and Execute consume only the +exact clean saved Model Sweep snapshot, and Inspect Result hands the finalized +output directory to the existing inspection workflow without changing the +active document. + +The enabled QML Preparation page presents the bound-source card, all role and +output choices, quality and baseline settings, committed scenario summaries, +the one temporary `ScenarioDraft`, plan evidence, and execution/result state. +Unit 19B connects it to the normal navigation rail, lazy page loader, global +command status, structured workflow context inspector, stable focus routing, +and Workspace creation card. Creating a new document requires the existing +explicit bound source; without one, the Python composition reports the exact +prerequisite and routes to Inspect. Opening an existing Preparation YAML still +routes directly to the editor without inventing a source binding. + +Preparation source, document-field, and scenario interactions follow the same +queued root-signal boundary as the established Dataset and Visualization +delegates. Native source binding presents visible text actions and may continue +to the Preparation page; an empty page offers explicit New or source-selection +actions without making navigation itself create or replace a document. Source, +role, category, output, quality-diagnostic, and Scenario controls never mutate +or destroy an active Loader, list model, or conditionally visible settings +section synchronously from the originating input handler. Matrix and baseline +numeric controls retain visible labels independently of their populated values, +and the adjacent Outputs and Quality diagnostics cards remain top-aligned as +either card expands. Native application reinspection confirmed explicit source +binding, Scenario creation, Matrix and Baseline expansion, persistent labels, +and stable card alignment without a presentation-path crash. + The QML Inspect workbench consumes only the typed Qt models owned by `InspectionController`. Workspace discovery is direct-child, symlink-excluding, newest-first, and revealed 20 entries at a time. Explicit source inspection can @@ -693,14 +905,14 @@ normalizes `file:` URLs before inspection. Workspace-source rows are the normal path for generated outputs; the external actions intentionally accept sources outside the active workspace. -Dataset, Run, Inspect, Visualization, and Activity navigation require only an -active workspace and present their own prerequisite states. YAML Preview alone -requires an open document. This keeps historical inspection, configured-result -review, and session plotting reachable without inventing a current -configuration. Dataset draft validation and Run saved-snapshot validation are -optional diagnostics. Neither authorizes Save or generation; those operations -retain their own fresh worker-authoritative validation at the existing trust -boundaries. +Dataset, Model Sweep, ML Preparation, Run, Inspect, Visualization, and Activity +navigation require only an active workspace and present their own prerequisite +states. YAML Preview alone requires an open document. This keeps historical +inspection, workflow-result review, and session plotting reachable without +inventing a current configuration. Dataset draft validation and Run +saved-snapshot validation are optional diagnostics. Neither authorizes Save or +generation; those operations retain their own fresh worker-authoritative +validation at the existing trust boundaries. The QML Visualization page projects both plot controllers without importing or running rendering code in the GUI process. Configured results start from a @@ -886,7 +1098,7 @@ Desktop verification is layered: | `tests/test_app_*.py` | Controller, draft, QML engine, and interaction contracts | | `scripts/check_qml.py` | Non-writing QML format, import, and lint checks | | dedicated Linux app CI job | App-extra typing and desktop tests under Qt offscreen execution | -| installed smoke tests | Both public QML command aliases plus packaged-QML responsive state, YAML-page creation, one controller interaction, teardown, and resource checks | +| installed smoke tests | Both public QML command aliases plus packaged-QML responsive state, YAML-page creation, workflow-page instantiation as each surface is enabled, one controller interaction, teardown, and resource checks | | distribution checker | Exact wheel/sdist module, QML, font, icon, license, and provenance inventories | | manual native acceptance | File dialogs, monitor/DPI behavior, themes, keyboard use, and perceived interaction | | native qualification workflow | Explicit Qt Quick/VTK bridge qualification, not a routine PR requirement | @@ -932,7 +1144,7 @@ GUI-2 is delivered one stage branch and pull request at a time: | 2 | Package the Precision Grid QML Workspace, Dataset, Visualization, and YAML/Save workflows | Complete; automated, remote, and native acceptance passed | | 3 | Migrate remaining GUI-1 workflows, reach parity, switch both launchers to QML, remove Widgets, and qualify `0.1.0a4` | Complete | | 4 | Add controlled sweep and preparation worker operations | Complete | -| 5 | Add structured sweep and preparation QML workflows | Approved next | +| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 22C; lifecycle hardening complete, packaged qualification and acceptance pending | | 6 | Build exact emitted-value 3D scene contracts | Pending | | 7 | Integrate native interactive 3D into QML | Pending | | 8 | Complete native-3D platform, distribution, documentation, and later-release qualification | Pending | @@ -1003,6 +1215,47 @@ six-row generation, configured plot, verified inspection, clean workspace reopen, and workspace-scoped installed smoke. Its lifecycle regression raised the exhaustively verified suite to 837 tests. +Stage 5 is implemented through Unit 22C on `feat/gui2-stage5`. The former +Dataset-only document and controller now provide one global exact-file +lifecycle for all three public configuration types. The complete structured +Sweep workflow is enabled in QML. Preparation source profiling, explicit +source binding, complete drafts and scenario editing, planning, execution, +Activity, persistent result state, and the packaged editor page are +implemented and enabled through normal shell navigation and guarded Workspace +creation. Finalized quality flags are also available through verified bounded +table inspection. A Qt-independent Preparation audit projection now validates +and flattens finalized scenario, partition, duplicate-state, structured-grid, +matrix, correlation, singular-value, and baseline evidence into exact typed row +contracts. It represents absent values explicitly and never infers leakage from +successful finalization. The worker now supplies the versioned private scenario +details only after containment, recorded-hash, exact-byte, and scenario +identity checks; their file identities contribute to the inspection revision. +Legacy bundles without recorded details remain inspectable with leakage evidence +unavailable. A focused audit-state object owns the fixed list models, and the +inspection controller validates the complete projection before accepting it, +rejects cross-kind audit payloads, and clears the state when inspection becomes +stale. A reusable packaged audit component now presents every fixed model in +quality/scenario, matrix, and baseline sections with bounded lists, contextual +summaries, explicit unavailable states, and responsive card stacking. It is +directly QML-tested and integrated as a Preparation-only Inspect tab. Exact +artifact-level audit issues remain visible beside accepted evidence, legacy +bundles retain an unavailable audit state, and changing away from an accepted +Preparation inspection hides the tab and restores Summary selection. +Operation-specific response contexts now prevent terminal configuration, +inspection, preview, and workflow responses from crossing document, source, +workspace, saved-snapshot, or plan/source-context replacement. Returned load +sources and preview blocks must match their requests. Packaged qualification, +complete gates, native lifecycle acceptance, and completion documentation +remain unfinished. Direct desktop slots now enforce global request idleness, +transient-editor focus, document +kind, operation-aware edit locking, owned nested state, and session-render +locking without trusting QML enablement. Shutdown consent is now bound to the +exact worker, transient editors, and document revision presented by its dialog; +protected finalization waits safely, and close processing sequences worker, +temporary-edit, and dirty-document resolution before native teardown. This +checkpoint changes private desktop ownership and presentation infrastructure +only; public scientific and distribution contracts remain unchanged. + ## Known current limitations - Both public desktop commands launch the single QML presentation. @@ -1013,9 +1266,12 @@ the exhaustively verified suite to 837 tests. arrays, diagnostics, and bounded tables. It also presents configured plot evidence, explicit session rendering, private Run activity, and guarded staging recovery. -- The visible QML application does not yet provide sweep or preparation - editors; Stage 4 supplies their nonvisual worker and controller foundation. - Exact emitted-value 3D presentation remains a later stage. +- The visible QML application now provides the complete structured Model Sweep + and ML Preparation editors and workflows. New Preparation documents require + an explicitly bound eligible inspection; existing YAML remains portable and + opens without a source path. Inspect now presents typed finalized Preparation + audit evidence and explicit unavailable legacy state. Exact emitted-value 3D + presentation remains a later stage. - Native folder dialogs and compositor behavior require human acceptance; headless tests do not automate them. - The current WSLg development host can use CPU rendering through Mesa @@ -1047,10 +1303,10 @@ standard library or Qt platform behavior, and otherwise make the smallest change that preserves scientific, lifecycle, accessibility, and data-safety contracts. Split a module only when a concrete stable responsibility can move with focused tests and less coupling. Reassess the QML shell and controller -hotspots after Stage 2 layout stabilization and again after Stage 3 removes the -frontend overlap. A database, web service, or external project-management plugin -does not solve the current local capability-loading or rendering-performance -constraints and is not justified by the implemented workflow. +hotspots at explicit stage checkpoints. A database, web service, or external +project-management plugin does not solve the current local capability-loading +or rendering-performance constraints and is not justified by the implemented +workflow. The 2026-07-23 maintenance audit records these concrete watchpoints without turning them into automatic refactors: @@ -1071,6 +1327,25 @@ Widgets presentation without introducing another controller layer. A split is justified only when it removes a named responsibility from one of these files without creating a second state owner or weakening a worker boundary. +The Stage 5 Unit 19B checkpoint records the new concentration points separately +rather than rewriting that historical audit: + +| File | Lines | Stage 5 responsibility to recheck | +| --- | ---: | --- | +| `qml/Carnopy/Main.qml` | 1,597 | Shell integration includes global documents, Sweep, and Preparation | +| `config_controller.py` | 1,218 | One exact file lifecycle with explicit three-kind draft dispatch | +| `desktop_controller.py` | 2,171 | Composition-owned guards and the QML command facade for four structured editors | +| `workflow_controller.py` | 1,312 | Shared plan/result lifecycle plus the Preparation-only source binding | +| `preparation_draft.py` | 1,457 | Complete public Preparation schema, capability projections, and scenario ownership | +| `qml/Carnopy/pages/PreparationPage.qml` | 1,069 | Dense but sectioned enabled editor awaiting native review | + +These sizes deserve review during Unit 22 hardening, but they do not by +themselves justify a session manager, event bus, generic editor framework, +second source subsystem, or speculative multidocument support. At this +checkpoint `ConfigurationController` remains limited to document/file +lifecycle, workflow state remains outside it, and nested scenario/comparison +responsibilities already live in focused draft modules. + Use focused checks while a desktop step is being developed. Run the full source, distribution, and preflight gates at stage or release boundaries, or earlier when a cross-cutting change warrants them. This keeps verification complete @@ -1093,8 +1368,12 @@ When changing the desktop, start at the owner of the behavior: | Workspace paths, marker, and trusted filesystem operation | `carnopy.app.workspace` | | Observable workspace state and recents | `carnopy.app.workspace_controller` | | Cross-workflow decisions and guards | `carnopy.app.desktop_controller` | -| Dataset document, merge, validation, Save, and dirty workflow | `carnopy.app.config_controller` and `config_document` | +| Global Dataset/Sweep/Preparation document, validation, Save, and dirty workflow | `carnopy.app.config_controller` and `config_document` | | Dataset or sampler editable state | `dataset_draft` and `sampler_draft` | +| Model Sweep editable state or temporary comparison | `sweep_draft` and `comparison_plot_draft` | +| Preparation editable state or temporary scenario | `preparation_draft` and `scenario_draft` | +| Sweep/Preparation plans, execution, results, or Preparation binding | `workflow_controller` and `workflow_models` | +| Preparation source eligibility and typed profiles | `source_inspection` and `inspection_controller` | | Configured visualization or temporary plot state | `visualization_draft`, `plot_draft`, and `mapping_draft` | | Configured plot evidence, preview authorization, and safe pair export | `configured_plot_results_controller`, `plot_artifacts`, and `plot_preview_provider` | | Inspected-data session plot edit and render lifecycle | `session_plot_controller` | diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index 0cb4c36..09b6c21 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -79,7 +79,7 @@ implementation. | 2 | Complete | Added the packaged QML shell and Dataset/YAML/Save workflows | | 3 | Complete | Reached parity, migrated both launchers, retired Widgets, and qualified `0.1.0a4` | | 4 | Complete | Added controlled sweep and preparation worker operations for the existing public contracts | -| 5 | Approved next | Add structured sweep and preparation QML workflows | +| 5 | In progress | Structured Sweep, Preparation, and typed audit inspection are enabled; packaged qualification and acceptance remain | | 6 | Pending | Build exact emitted-value 3D scenes | | 7 | Pending | Add native interactive 3D to QML | | 8 | Pending | Qualify native 3D packaging, platforms, and a later release | @@ -192,7 +192,7 @@ The separate WSLg launch-hardening follow-up remains desktop maintenance. Its native XCB/WSLg acceptance passed on 2026-08-09 with a real six-row generation, configured plot, verified inspection, clean workspace reopen, and a fixed workspace-scoped smoke lifecycle; exhaustive verification now collects 837 -tests. Stage 5 is now the approved next stage. +tests. Stage 5 is now in progress on its dedicated feature branch. ## Stage 5: sweep and preparation QML workflows @@ -211,6 +211,215 @@ audits, partition summaries, correlations, singular values, rank, conditioning, and baseline metrics. Missing optional dependencies disable only the affected feature and provide exact installation guidance. +### Implementation checkpoint: Units 1–22C + +Stage 5 is in progress on `feat/gui2-stage5`. The implemented checkpoint keeps +one globally active configuration document while extending its exact-byte, +dirty-state, reformat, external-change, atomic-Save, exclusive-Save-As, and +saved-snapshot contracts across Dataset, Model Sweep, and Preparation YAML. +`ConfigurationController` owns only that shared document lifecycle and the +three focused drafts; workflow planning, execution, Activity, results, +inspection, and Preparation source context remain in their existing focused +controllers. Generic worker loading dispatches directly from the required +`document_type` discriminator rather than parser order. + +The structured Model Sweep workflow is enabled in the normal QML shell. It +supports the complete current sweep schema, comparison-plot snapshots and +temporary editing, typed plan and result projections, revision-bound planning, +controlled execution, cancellation, protected finalization, persistent +finalized-result identity, and exact Inspect handoff. + +Preparation is implemented through the Unit 19B visible-shell checkpoint: + +- inspection derives a private typed preparation profile from verified source + metadata and established preparation field-resolution logic; +- `PreparationWorkflowController` owns an explicit immutable copy of the + selected inspection snapshot, so later browsing in Inspect cannot silently + change the effective preparation source; +- the complete current role, categorical, output, quality, baseline, and + eight-scenario schema is represented by Python-owned drafts; +- temporary scenario edits survive navigation and cannot leak into Save, + validation, planning, execution, document replacement, workspace + replacement, or shutdown; +- preparation planning, execution, cancellation, protected finalization, + Activity, result relation, and exact Inspect handoff consume the global + saved document plus the explicit source binding; and +- the packaged `PreparationPage` and scenario editor are available through + normal navigation, the Workspace source prerequisite, the global command + lifecycle, and the workflow context inspector. + +Unit 19A adds the Python-owned New Preparation lifecycle. The configuration +controller composes the packaged Preparation template through the global +document lifecycle, while the desktop composition requires an explicit bound +source before creation. A missing binding produces an exact prerequisite and +routes to Inspect; direct internal configuration calls cannot bypass that +composition guard. No new QML surface is enabled by 19A. + +Unit 19B enables the normal Preparation surface without adding another state +owner. Workspace creation routes an unbound user to Inspect, dirty replacement +uses the global discard decision, Preparation documents open directly in the +structured page, and navigation, command status, focus routing, context state, +and source-change actions bind the existing authoritative controllers. Focused +QML coverage also holds optional ML and analysis controls unavailable under an +app-only capability projection while keeping the page usable. + +Unit 20A begins finalized audit exposure without adding presentation state. +Current Preparation `quality_flags` are now an integrity-verified table in the +worker inspection catalog and therefore contribute to the exact inspection +revision and reuse the established 500-row worker blocks and 100-row local +pages. An invalid optional flags artifact remains omitted from table control and +is reported through the existing Preparation quality error instead of making +the main bundle uninspectable. + +Unit 20B adds the Qt-independent `PreparationAuditProjection` and its exact +role contracts. It validates and deterministically flattens finalized quality, +scenario and partition, duplicate-state, structured-grid, matrix, correlation, +singular-value, and baseline evidence into detached typed rows. Missing numeric +evidence has explicit availability state, mismatched worker evidence is +rejected, and absent scenario-detail evidence never produces inferred leakage +claims. A versioned private scenario-detail input is defined for the verified +`scenario.json` evidence that Unit 20C will supply; no controller or QML wiring +is part of Unit 20B. + +Unit 20C completes the private worker/controller integration. Current scenario +audit artifacts are resolved within the finalized bundle, checked against their +recorded hashes, read as exact bytes, and required to match the manifest's +scenario name, kind, and partition counts before their leakage evidence enters +the private worker payload. Their file identities contribute to the inspection +revision. Legacy bundles without recorded scenario audit artifacts remain +inspectable but expose no leakage evidence. A focused `PreparationAuditModel` +owns the exact section list models, while `InspectionController` validates the +complete projection before accepting the response, rejects audit data attached +to another source kind, and clears audit state when inspection becomes stale. + +Unit 21A adds the reusable packaged `PreparationAuditView` without yet changing +normal Inspect navigation. The component consumes only the focused typed audit +object, groups quality/scenario, matrix, and baseline evidence into explicit +sections, uses bounded reusable list delegates for potentially large evidence, +and distinguishes unavailable evidence from an available section with no +findings. It retains scenario, partition, fit, target, model, and source-group +context in visible summaries, stacks its cards at narrow widths, and is directly +instantiated with populated and unavailable projections under the QML warning +capture. This deliberately leaves placement in the Inspect workflow to the +separate Unit 21B integration boundary. + +Unit 21B integrates that component as a fifth Inspect tab only while a +Preparation bundle has been successfully accepted. Dataset and Model Sweep +inspections retain their existing four tabs. Current bundles present the typed +quality/scenario, matrix, and baseline evidence plus exact artifact-level audit +issues; legacy bundles retain an explicit unavailable state rather than losing +the audit surface or fabricating evidence. Starting another inspection, a +failed or stale inspection, or accepting a non-Preparation source hides the tab +and returns a selected audit tab to Summary. The page remains responsive at +narrow widths, and QML continues to consume only the controller's typed models. + +The native inspection checkpoint after Unit 21B found and repaired two +presentation-path defects before lifecycle hardening continued. Text-only +`AppButton` instances marked compact now retain visible labels instead of +rendering as empty squares; the Preparation source card exposes visible bind, +clear, and continuation actions, while the empty Preparation page can create a +bound-source document without a Workspace detour. Scenario Add, Edit, Commit, +Cancel, list mutation, and nested-editor model changes now cross the existing +queued root-signal boundary, preventing a Loader/model rebind inside its own +native click handler. Continued native inspection exposed the same synchronous +rebind defect when Matrix diagnostics revealed its settings. Source actions, +roles, categorical settings, source policy, outputs, matrix diagnostics, and +baseline diagnostics now all cross that queued boundary as well. Focused QML +interaction coverage invokes the visible Scenario and Matrix controls rather +than substituting direct Python calls. Native reinspection confirmed that the +source, Scenario, Matrix, and Baseline controls no longer crash. It also found +that populated quality fields lost their placeholder-only descriptions and the +shorter Outputs card was vertically centered as Quality diagnostics expanded. +The five settings now retain persistent visible labels and both cards remain +top-aligned. Native visual reinspection confirmed that the labels remain +visible and the Outputs and Quality diagnostics headings remain aligned while +both diagnostic sections are toggled. The Unit 21B presentation checkpoint is +therefore accepted. + +Unit 22A hardens terminal-response adoption without adding another request or +identity framework. Configuration requests now retain the workspace, +document-replacement generation, document kind, exact validation bytes, and +requested source relevant to that operation. Inspection requests retain the +workspace, source, revision, table, worker-block offset, and local-page offset. +Workflow requests retain the workspace, requested configuration path, exact +active saved snapshot, and immutable execution plan/source context. A matching +request UUID remains necessary, but a terminal success or failure is adopted +only while this operation-specific semantic context is still current. This +prevents late validation, Save, load, inspection, preview, plan, or execution +responses from replacing newer state after direct or otherwise forbidden +context mutation. Worker load results must also return the exact requested +source, and preview payloads must identify the requested worker block. + +These guards preserve intentional semantics: ordinary edits made during an +execution do not change its captured snapshot, and navigation or identical +semantic state does not invalidate a response. Focused controller tests cover +obsolete successes and failures, identical-byte document replacement, +no-write stale Save, source mismatches, preview-block mismatches, and workspace +replacement. Cross-controller action enforcement and shutdown/transition +hardening were deliberately left to separate Units 22B and 22C. + +Unit 22B completes the cross-controller action-enforcement half of that +boundary. Every QML-callable global configuration lifecycle or worker-start +slot rechecks both transient-editor state and global request idleness in +`DesktopController`; disabled controls are presentation only. Direct Plan and +Execute calls focus the active comparison or scenario editor instead of +reaching a workflow controller. Dataset and configured-Visualization edit +slots now use the same document-kind and operation-aware `canEdit` policy that +already guarded Sweep and Preparation fields. Configuration validation, load, +Save, and planning lock their owning editor, while Dataset, Sweep, or +Preparation execution continues to allow ordinary draft edits beside its +immutable active snapshot. Session-plot field and mapping slots likewise reject +changes while their render worker owns the submitted request. Cancellation and +Force Stop remain available only through their established active-operation +controllers. + +Focused facade regressions call the Python slots directly, bypassing QML +enablement. They cover every global lifecycle and worker-start family, both +nested-editor kinds, Dataset and Visualization editing under configuration +work and execution, owned-object checks, and session rendering. That checkpoint +left multi-step shutdown and the remaining cross-workflow transition matrix to +Unit 22C. + +Unit 22C completes that shutdown and transition boundary. Busy-close consent is +bound to the exact worker session that produced the dialog, and a delayed +confirmation cannot cancel a replacement request even when its workflow and +operation names are identical. Pending cooperative cancellation likewise +retains the accepted session identity. Protected finalization is wait-only and +continues closing only after the worker releases the coordinator. Sweep and +Preparation cancellation dialogs now say Cancel rather than Force stop; only +the session-render path uses force-stop wording. + +Transient-edit consent is bound to the exact configured plot, session plot, +Sweep comparison, and Preparation scenario identities shown to the user. +Dirty-document consent is bound to the same configuration object and +configuration-state revision. Changed state rejects the old confirmation +instead of discarding a replacement edit or newer document state. After a +worker finishes, the controller advances explicitly through transient-edit +Cancel and then dirty-document discard; it emits the final window-close request +only after every remaining guard passes. Focused transition tests cover stale +busy, transient, and dirty confirmations, same-operation worker replacement, +protected finalization, the complete worker-to-edit-to-dirty sequence, and the +truthful QML policy labels. Existing focused configuration and workflow tests +continue to cover exact-byte plan restoration, explicit Preparation rebinding, +immutable execution snapshots, stale/unrelated persistent results, cancellation, +and protected finalization. + +No public YAML schema, CLI command, Python API, scientific algorithm, manifest, +result model, artifact layout, provenance contract, or dependency boundary has +changed. Focused tests accompany each completed implementation unit; the +complete Stage 5 gate and native acceptance remain pending. + +The remaining implementation order after Unit 22C is: + +1. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. +2. Unit 24 records completion only after automated and manual acceptance. + +The Unit 21B audit-presentation checkpoint and its focused native repair cycle +are complete. The hardened lifecycle paths require their focused native +inspection at the Unit 22C checkpoint, and the final installed application is +inspected after Unit 23; acceptance must not be deferred until the +documentation-only completion unit. + ## Stage 6: exact scientific 3D scenes Worker-prepared scenes support dataset runs and prepared main or scenario diff --git a/ML_PREPARATION_ROADMAP.md b/ML_PREPARATION_ROADMAP.md index 884690a..ac6ce26 100644 --- a/ML_PREPARATION_ROADMAP.md +++ b/ML_PREPARATION_ROADMAP.md @@ -50,6 +50,54 @@ auxiliary export is explicitly enabled. The manifest records the original column, vocabulary, code order, missing-value code, dtype, and output-array name. +## Desktop exposure checkpoint + +GUI-2 Stage 4 established worker-authoritative, revision-bound Preparation +planning and execution without adding a visible editor. Stage 5 is now +implemented through Unit 21B: + +- Inspect derives typed Preparation eligibility and capability projections + from verified dataset-run or model-sweep metadata; +- the Preparation workflow explicitly binds a copied inspection path, + revision, descriptor, and profile, so ordinary inspection of another + artifact cannot silently change scientific execution context; +- source context remains outside portable Preparation YAML and source changes + never rewrite selected configuration; +- Python-owned drafts expose the complete current features, categoricals, + targets, auxiliary fields, source policy, outputs, matrix settings, baseline + settings, all eight scenario kinds, and ordered transformations; +- planning and execution consume an exact saved Preparation snapshot plus the + explicit binding, and finalized results retain current, stale, or unrelated + identity independently of page lifetime; and +- the packaged Preparation page and scenario editor are enabled through normal + navigation, guarded Workspace creation, global document commands, source + actions, and typed workflow context state; and +- finalized `data/quality_flags.parquet` is available through the same + containment-checked, hash-verified, revision-bound, and bounded table-preview + path as other Preparation tables, while corruption remains a reported quality + issue rather than hiding the main bundle; and +- a Qt-independent audit projection validates and deterministically flattens + finalized scenario, partition, duplicate-state, structured-grid, matrix, + correlation, singular-value, and baseline evidence into typed row contracts, + with explicit missing-value state and no inferred leakage claims when verified + scenario-detail evidence is absent; and +- the worker supplies those scenario details only after bundle containment, + recorded-hash, exact-byte, name, kind, and partition checks, while their file + identities contribute to the inspection revision; the inspection controller + validates the full projection before publishing its focused typed models and + clears them when inspection becomes stale; and +- a reusable packaged audit component presents those exact models in bounded, + contextual, responsive quality/scenario, matrix, and baseline sections and is + directly tested with populated and unavailable evidence; and +- Inspect exposes that component only for a successfully accepted Preparation + bundle, keeps exact audit-artifact issues beside accepted evidence, preserves + explicit legacy-unavailable state, and hides an obsolete selected audit tab + when the inspected source changes. + +This is desktop exposure of the implemented preparation contract, not new +preparation science. Lifecycle hardening, packaged qualification, and native +acceptance remain later Stage 5 work. + ## Reviewed future direction — Optional PyTorch dataset export The product sequence now prioritizes desktop workflow depth and then source diff --git a/README.md b/README.md index 266e6af..4d6d5e8 100644 --- a/README.md +++ b/README.md @@ -162,21 +162,38 @@ Its workflow is: ```text Workspace → Dataset → YAML Preview → Run → Inspect → Visualization + → Model Sweeps → Plan/Execute → Inspect + → ML Preparation → Plan/Execute → Inspect → Activity and Recovery ``` - **Dataset** edits all three dataset modes and projects row counts without importing the scientific stack into the GUI process. +- **Model Sweeps** edits the complete current sweep schema, including + comparison plots, and exposes worker-verified Plan, Execute, cancellation, + result status, and exact Inspect handoff. +- **ML Preparation** requires an explicitly bound eligible source from Inspect, + then edits the complete current Preparation schema and exposes the same + controlled Plan, Execute, cancellation, result, and Inspect lifecycle. - **YAML Preview** shows the deterministic complete document. Save and Save As validate those exact bytes in a worker before writing. - **Run** validates and generates an exact clean saved snapshot. -- **Inspect** presents provenance, diagnostics, logical arrays, and bounded - order-preserving table pages. +- **Inspect** presents provenance, diagnostics, logical arrays, bounded + order-preserving table pages, and finalized Preparation audit evidence. - **Visualization** verifies recorded configured-plot evidence and supports explicit session rendering from inspected columns. - **Activity and Recovery** projects private request records and removes only explicitly selected, rescanned staging artifacts. +The current source application keeps one active Dataset, Model Sweep, or +Preparation configuration with one Save, Reload, Close, dirty-state, and YAML +Preview lifecycle. Generic Open dispatches from the YAML `document_type`. +Preparation source profiling, explicit source binding, complete structured +drafts, planning, execution, and its packaged editor are implemented on the +Stage 5 branch and enabled in the normal shell. Inspect now exposes the typed +Preparation audit view for accepted Preparation bundles, while lifecycle +hardening, packaged qualification, and acceptance remain unfinished. + Scientific generation, inspection, and Matplotlib rendering run in short-lived workers. The QML process does not import CoolProp, NumPy, pandas, PyArrow, or Matplotlib. PNG and SVG use hash-bound in-app previews; PDF opens only after an @@ -358,6 +375,11 @@ carnopy init model_sweep sweep.yaml carnopy sweep sweep.yaml ``` +In the current source desktop, create or open that YAML through Workspace and +use **Model Sweeps** for structured editing, planning, controlled execution, +cancellation, result review, and Inspect handoff. The published `0.1.0a4` +desktop predates this Stage 5 surface; its CLI behavior is unchanged. + Preparation reads an existing immutable run or sweep bundle and never calls a thermodynamic backend: @@ -435,11 +457,14 @@ thermophysical engines and data sources ``` The accepted direction is workflow depth now, source breadth next, and advanced -model breadth later. GUI-2 Stage 4 has now brought the existing model-sweep and +model breadth later. GUI-2 Stage 4 brought the existing model-sweep and preparation workflows into the desktop's controlled nonvisual worker boundary. -Stage 5 adds their visible structured QML workflows. After that milestone, -Carnopy will establish a validated import/source contract and one -evidence-driven source expansion. +Stage 5 is in progress: the complete structured Model Sweep and Preparation +editors and typed Preparation audit inspection are enabled, while lifecycle +hardening, packaged qualification, and acceptance remain. After that +milestone, Carnopy +will establish a validated import/source contract and one evidence-driven +source expansion. Detailed source and model candidates are maintainer planning rather than public support promises. Optional PyTorch export, exact 3D, and automation remain @@ -477,8 +502,9 @@ gates, native acceptance, [PyPI publication](https://pypi.org/project/carnopy/0. [GitHub prerelease](https://github.com/gcalpay/carnopy/releases/tag/v0.1.0a4), and [version-specific Zenodo archive](https://doi.org/10.5281/zenodo.21709965) are complete. GUI-2 Stage 4 is implemented on the current development branch -without changing the published `0.1.0a4` artifacts; Stage 5 is the approved -next stage for the visible sweep and preparation workflow-depth milestone. +without changing the published `0.1.0a4` artifacts. Stage 5 is in progress on +its dedicated feature branch; its structured Sweep and Preparation +desktop implementation does not change the published `0.1.0a4` artifacts. ## License diff --git a/docs/agent-guides/DEVELOPMENT.md b/docs/agent-guides/DEVELOPMENT.md index 0801fd2..3c4b752 100644 --- a/docs/agent-guides/DEVELOPMENT.md +++ b/docs/agent-guides/DEVELOPMENT.md @@ -101,6 +101,12 @@ follow-up work. Before handing off any completed implementation: Do not wait for the maintainer to request this synchronization. +When an accepted plan is implemented as incremental units, synchronize the +applicable documentation at the end of every unit before its commit handoff. +Do not accumulate stale documentation for a final documentation-only unit; +that final unit may record completion evidence, but it must not be the first +time earlier behavior changes are documented. + Use: - `rg` for searches; diff --git a/docs/agent-guides/SCIENTIFIC_CONTRACTS.md b/docs/agent-guides/SCIENTIFIC_CONTRACTS.md index 294bf34..103c099 100644 --- a/docs/agent-guides/SCIENTIFIC_CONTRACTS.md +++ b/docs/agent-guides/SCIENTIFIC_CONTRACTS.md @@ -138,8 +138,21 @@ planning and controlled execution for model sweeps and preparation. Planning does not create output paths or fit baseline estimators. Execution recomputes and verifies the current plan in its short-lived worker, persists Activity only for execution, and protects the final immutable rename after all source, -serialization, and hashing checks succeed. Visible sweep and preparation QML -workflows remain Stage 5. +serialization, and hashing checks succeed. + +GUI-2 Stage 5 is in progress without changing those public contracts. Current +source uses one exact-byte desktop configuration lifecycle for the three public +document types, enables the complete structured Model Sweep QML workflow, and +implements Preparation source profiling, explicit source binding, structured +drafts, planning, execution, an enabled structured editor, and typed inspection +of finalized Preparation quality, scenario, matrix, and baseline evidence. +Creating a new Preparation document requires an explicitly bound eligible +inspection, while opening portable Preparation YAML never invents or serializes +a source. The source binding is private execution context and never adds a path +to Preparation YAML. QML remains a typed presentation of the +same worker-authoritative schemas and scientific operations; no Stage 5 draft, +profile, issue model, plan projection, or controller property is a public +Python or inspection interface. Manual desktop plots must use the inspected dataset source and its integrity revision. The GUI supplies a session-only public-shaped request; the worker diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py index b1a975b..42748ee 100644 --- a/scripts/check_distribution.py +++ b/scripts/check_distribution.py @@ -48,6 +48,9 @@ "qml/Carnopy/components/ChoiceList.qml", "qml/Carnopy/components/SearchableChoiceList.qml", "qml/Carnopy/components/CommandBar.qml", + "qml/Carnopy/components/ComparisonPlotEditor.qml", + "qml/Carnopy/components/PreparationAuditView.qml", + "qml/Carnopy/components/PreparationScenarioEditor.qml", "qml/Carnopy/components/ContextInspector.qml", "qml/Carnopy/components/ActivityContextInspector.qml", "qml/Carnopy/components/InspectionContextInspector.qml", @@ -65,12 +68,16 @@ "qml/Carnopy/components/StatusBadge.qml", "qml/Carnopy/components/ToastHost.qml", "qml/Carnopy/components/ValidationIssue.qml", + "qml/Carnopy/components/WorkflowContextInspector.qml", + "qml/Carnopy/components/WorkflowRunPanel.qml", "qml/Carnopy/components/WorkspaceOperationDialog.qml", "qml/Carnopy/pages/EmptyStatePage.qml", "qml/Carnopy/pages/ActivityPage.qml", "qml/Carnopy/pages/DatasetPage.qml", "qml/Carnopy/pages/HelpPage.qml", "qml/Carnopy/pages/InspectPage.qml", + "qml/Carnopy/pages/ModelSweepPage.qml", + "qml/Carnopy/pages/PreparationPage.qml", "qml/Carnopy/pages/RunPage.qml", "qml/Carnopy/pages/SettingsPage.qml", "qml/Carnopy/pages/WorkspacePage.qml", diff --git a/src/carnopy/app/comparison_plot_draft.py b/src/carnopy/app/comparison_plot_draft.py new file mode 100644 index 0000000..2cc8264 --- /dev/null +++ b/src/carnopy/app/comparison_plot_draft.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import copy +from collections.abc import Mapping, Sequence +from typing import Any + +from pydantic import ValidationError +from PySide6.QtCore import Property, QObject, Signal, Slot + +from carnopy.app.draft_models import DraftItem, DraftListModel +from carnopy.app.mapping_draft import MappingDraftModel + +COMPARISON_KINDS = ("property_comparison", "property_delta") +COMPARISON_AXES = ("temperature", "pressure", "vapor_mass_fraction") +COMPARISON_GROUP_FIELDS = (*COMPARISON_AXES, "saturation_endpoint") +COMPARISON_FILTER_FIELDS = COMPARISON_GROUP_FIELDS +DELTA_METRICS = ("signed_relative_difference", "signed_absolute_difference") +PLOT_SCALES = ("linear", "log") +PLOT_FORMATS = ("png", "svg", "pdf") + + +class ComparisonPlotDraft(QObject): + """Own one detached, temporary model-sweep comparison-plot edit.""" + + changed = Signal() + validity_changed = Signal() + + def __init__( + self, + *, + selected_models: Sequence[str], + reference_model: str, + fluids: Sequence[str], + properties: Sequence[str], + fluid_aliases: Mapping[str, str] | None = None, + categorical_values: Mapping[str, Sequence[str]] | None = None, + payload: Mapping[str, object] | None = None, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + self.model_choices = DraftListModel(self) + self.filters = MappingDraftModel(self, numeric_values=True) + self.filters.changed.connect(self._state_changed) + self._selected_models = tuple(selected_models) + self._reference_model = reference_model + self._fluids = tuple(fluids) + self._properties = tuple(properties) + self._fluid_aliases = { + str(alias).casefold(): str(canonical) + for alias, canonical in (fluid_aliases or {}).items() + } + categorical = { + str(field): tuple(str(value) for value in values) + for field, values in (categorical_values or {}).items() + } + self._name = "" + self._kind = "property_comparison" + self._fluid = self._fluids[0] if self._fluids else "" + self._property_name = self._properties[0] if self._properties else "" + self._x_field = "temperature" + self._group_by = "" + self._explicit_models = False + self._models: tuple[str, ...] = () + self._delta_metric = "signed_relative_difference" + self._value_scale = "linear" + self._output_format = "" + self.filters.configure( + COMPARISON_FILTER_FIELDS, + field_kinds={"saturation_endpoint": "categorical"}, + value_choices={"saturation_endpoint": categorical.get("saturation_endpoint", ())}, + ) + if payload is not None: + self.load_payload(payload) + else: + self._refresh_models() + + def get_name(self) -> str: + return self._name + + @Slot(str) + def set_name(self, value: str) -> None: + self._set_scalar("_name", value) + + name = Property(str, get_name, set_name, notify=changed) + + def get_kind(self) -> str: + return self._kind + + @Slot(str) + def set_kind(self, value: str) -> None: + self._set_scalar("_kind", value) + + kind = Property(str, get_kind, set_kind, notify=changed) + + def get_fluid(self) -> str: + return self._fluid + + @Slot(str) + def set_fluid(self, value: str) -> None: + self._set_scalar("_fluid", value) + + fluid = Property(str, get_fluid, set_fluid, notify=changed) + + def get_property_name(self) -> str: + return self._property_name + + @Slot(str) + def set_property_name(self, value: str) -> None: + self._set_scalar("_property_name", value) + + propertyName = Property(str, get_property_name, set_property_name, notify=changed) + + def get_x_field(self) -> str: + return self._x_field + + @Slot(str) + def set_x_field(self, value: str) -> None: + self._set_scalar("_x_field", value) + + xField = Property(str, get_x_field, set_x_field, notify=changed) + + def get_group_by(self) -> str: + return self._group_by + + @Slot(str) + def set_group_by(self, value: str) -> None: + self._set_scalar("_group_by", value) + + groupBy = Property(str, get_group_by, set_group_by, notify=changed) + + def get_explicit_models(self) -> bool: + return self._explicit_models + + @Slot(bool) + def set_explicit_models(self, value: bool) -> None: + updated = bool(value) + if updated == self._explicit_models: + return + self._explicit_models = updated + if updated and not self._models: + self._models = tuple( + model + for model in self._selected_models + if self._kind != "property_delta" or model != self._reference_model + ) + self._state_changed() + + explicitModels = Property(bool, get_explicit_models, set_explicit_models, notify=changed) + + def get_delta_metric(self) -> str: + return self._delta_metric + + @Slot(str) + def set_delta_metric(self, value: str) -> None: + self._set_scalar("_delta_metric", value) + + deltaMetric = Property(str, get_delta_metric, set_delta_metric, notify=changed) + + def get_value_scale(self) -> str: + return self._value_scale + + @Slot(str) + def set_value_scale(self, value: str) -> None: + self._set_scalar("_value_scale", value) + + valueScale = Property(str, get_value_scale, set_value_scale, notify=changed) + + def get_output_format(self) -> str: + return self._output_format + + @Slot(str) + def set_output_format(self, value: str) -> None: + self._set_scalar("_output_format", value) + + outputFormat = Property(str, get_output_format, set_output_format, notify=changed) + + def get_model_choices(self) -> QObject: + return self.model_choices + + modelChoices = Property(QObject, get_model_choices, constant=True) + + def get_fluid_choices(self) -> list[str]: + values = list(self._fluids) + if self._fluid and not self._fluid_matches_selected(self._fluid): + values.append(self._fluid) + return values + + fluidChoices = Property(list, get_fluid_choices, notify=changed) + propertyChoices = Property(list, lambda self: list(self._properties), constant=True) + kindChoices = Property(list, lambda _self: list(COMPARISON_KINDS), constant=True) + xChoices = Property(list, lambda _self: list(COMPARISON_AXES), constant=True) + groupByChoices = Property( + list, + lambda _self: ["", *COMPARISON_GROUP_FIELDS], + constant=True, + ) + deltaMetricChoices = Property(list, lambda _self: list(DELTA_METRICS), constant=True) + scaleChoices = Property(list, lambda _self: list(PLOT_SCALES), constant=True) + formatChoices = Property(list, lambda _self: ["", *PLOT_FORMATS], constant=True) + + def get_filters(self) -> QObject: + return self.filters + + filtersModel = Property(QObject, get_filters, constant=True) + + def get_locally_valid(self) -> bool: + return not self.get_issue() + + locallyValid = Property(bool, get_locally_valid, notify=validity_changed) + + def get_issue(self) -> str: + try: + self.payload() + except ValueError as exc: + return str(exc) + return "" + + issue = Property(str, get_issue, notify=validity_changed) + + def get_first_invalid_field(self) -> str: + issue = self.get_issue().casefold() + if not issue: + return "" + if not self.filters.get_valid(): + return "sweep.comparison.active.filters" + if "name" in issue: + return "sweep.comparison.active.name" + if "fluid" in issue: + return "sweep.comparison.active.fluid" + if "model" in issue or "reference" in issue: + return "sweep.comparison.active.models" + if "property" in issue: + return "sweep.comparison.active.property" + if "kind" in issue: + return "sweep.comparison.active.kind" + if "group" in issue: + return "sweep.comparison.active.group_by" + if "x field" in issue or "\nx\n" in issue: + return "sweep.comparison.active.x" + if "metric" in issue: + return "sweep.comparison.active.delta_metric" + if "scale" in issue: + return "sweep.comparison.active.value_scale" + if "format" in issue: + return "sweep.comparison.active.format" + return "sweep.comparison.active" + + firstInvalidField = Property(str, get_first_invalid_field, notify=validity_changed) + + def get_first_invalid_row(self) -> int: + return self.filters.get_first_invalid_row() if not self.filters.get_valid() else -1 + + firstInvalidRow = Property(int, get_first_invalid_row, notify=validity_changed) + + @Slot(str, bool, result=bool) + def set_model_selected(self, model: str, selected: bool) -> bool: + if model not in self._selected_models: + return False + if selected and self._kind == "property_delta" and model == self._reference_model: + return False + values = list(self._models) + if selected and model not in values: + values.append(model) + elif not selected and model in values: + values.remove(model) + else: + return False + self._models = tuple(item for item in self._selected_models if item in values) + self._state_changed() + return True + + def load_payload(self, value: Mapping[str, object]) -> None: + self._name = str(value.get("name", "")) + self._kind = str(value.get("kind", "property_comparison")) + self._fluid = str(value.get("fluid", "")) + self._property_name = str(value.get("property", "")) + self._x_field = str(value.get("x", "temperature")) + self._group_by = str(value.get("group_by", "")) + raw_models = value.get("models") + self._explicit_models = isinstance(raw_models, (list, tuple)) + self._models = ( + tuple(str(item) for item in raw_models) if isinstance(raw_models, (list, tuple)) else () + ) + self._delta_metric = str(value.get("delta_metric", "signed_relative_difference")) + self._value_scale = str(value.get("value_scale", "linear")) + self._output_format = str(value.get("format", "")) + raw_filters = value.get("filters") + self.filters.load_mapping(raw_filters if isinstance(raw_filters, Mapping) else {}) + self._refresh_models() + self.validity_changed.emit() + self.changed.emit() + + def payload(self) -> dict[str, Any]: + from carnopy.config.sweep import ComparisonPlotConfig + + if not self._fluid_matches_selected(self._fluid): + raise ValueError(f"comparison plot fluid {self._fluid!r} is not selected") + if self._property_name not in self._properties: + raise ValueError(f"comparison plot property {self._property_name!r} is not selected") + if self._kind not in COMPARISON_KINDS: + raise ValueError("comparison plot kind is invalid") + if self._x_field not in COMPARISON_AXES: + raise ValueError("comparison plot x field is invalid") + if self._group_by and self._group_by not in COMPARISON_GROUP_FIELDS: + raise ValueError("comparison plot group_by field is invalid") + selected_models: list[str] | None = None + if self._explicit_models: + selected_models = list(self._models) + if any(model not in self._selected_models for model in selected_models): + raise ValueError("comparison models must be selected sweep models") + if self._kind == "property_delta" and self._reference_model in selected_models: + raise ValueError("property_delta models cannot include the reference model") + result: dict[str, Any] = { + "name": self._name.strip(), + "kind": self._kind, + "fluid": self._fluid, + "property": self._property_name, + "x": self._x_field, + } + if self._group_by: + result["group_by"] = self._group_by + filters = self.filters.mapping() + if filters: + result["filters"] = filters + if selected_models is not None: + result["models"] = selected_models + result["delta_metric"] = self._delta_metric + if self._value_scale != "linear": + result["value_scale"] = self._value_scale + if self._output_format: + result["format"] = self._output_format + try: + validated = ComparisonPlotConfig.model_validate(result) + except ValidationError as exc: + raise ValueError(str(exc)) from exc + return validated.model_dump(mode="json", by_alias=True, exclude_none=True) + + def raw_state(self) -> tuple[object, ...]: + return ( + self._name, + self._kind, + self._fluid, + self._property_name, + self._x_field, + self._group_by, + self._explicit_models, + self._models, + self._delta_metric, + self._value_scale, + self._output_format, + self.filters.raw_rows(), + ) + + def detached_payload(self) -> dict[str, Any]: + return copy.deepcopy(self.payload()) + + def _set_scalar(self, attribute: str, value: str) -> None: + if getattr(self, attribute) == value: + return + setattr(self, attribute, value) + self._state_changed() + + def _state_changed(self) -> None: + self._refresh_models() + self.validity_changed.emit() + self.changed.emit() + + def _refresh_models(self) -> None: + self.model_choices.replace( + DraftItem( + value=model, + display=model.upper(), + canonical=model, + compatible=(self._kind != "property_delta" or model != self._reference_model), + selected=model in self._models, + issue=( + "Delta plots compare against the reference model implicitly." + if self._kind == "property_delta" and model == self._reference_model + else "" + ), + ) + for model in self._selected_models + ) + + def _fluid_matches_selected(self, value: str) -> bool: + requested = self._canonical_fluid(value) + return any(self._canonical_fluid(selected) == requested for selected in self._fluids) + + def _canonical_fluid(self, value: str) -> str: + return self._fluid_aliases.get(value.casefold(), value).casefold() diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index 13f68c9..e42c3a4 100644 --- a/src/carnopy/app/config_controller.py +++ b/src/carnopy/app/config_controller.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from typing import Any, cast @@ -10,7 +11,8 @@ from carnopy.app.client import WorkerClient from carnopy.app.config_document import ( ConfigDocumentError, - DatasetConfigDocument, + ConfigurationDocument, + DocumentType, ExternalModificationError, SavedConfigSnapshot, document_from_worker_payload, @@ -21,25 +23,37 @@ write_new_config, ) from carnopy.app.dataset_draft import DatasetDraft +from carnopy.app.preparation_draft import PreparationDraft from carnopy.app.protocol import RequestType from carnopy.app.request_coordinator import ( DesktopRequestCoordinator, RequestOutcome, RequestSession, ) +from carnopy.app.sweep_draft import SweepDraft from carnopy.app.visualization_draft import VisualizationDraft from carnopy.app.workspace import Workspace from carnopy.templates import template_text -class DatasetConfigController(QObject): - """Own the complete desktop dataset-configuration workflow.""" +@dataclass(frozen=True) +class _ConfigurationRequestContext: + action: str + workspace_root: Path | None + document_generation: int + document_type: DocumentType | None + document_sha256: str + requested_path: Path | None + + +class ConfigurationController(QObject): + """Own one exact desktop configuration document and its file lifecycle.""" state_changed = Signal() status_message_changed = Signal() draft_changed = Signal(bool) document_state_changed = Signal() - document_opened = Signal() + configuration_document_opened = Signal(str) warning_requested = Signal(str, str) mode_change_requested = Signal(str) save_path_requested = Signal(str) @@ -58,6 +72,9 @@ def __init__( dataset_draft: DatasetDraft | None = None, visualization_draft: VisualizationDraft | None = None, parent: QObject | None = None, + *, + sweep_draft: SweepDraft | None = None, + preparation_draft: PreparationDraft | None = None, ) -> None: super().__init__(parent) self._owns_coordinator = coordinator is None @@ -67,11 +84,15 @@ def __init__( self.coordinator = coordinator self.dataset_draft = dataset_draft or DatasetDraft(self) self.visualization_draft = visualization_draft or VisualizationDraft(self) + self.sweep_draft = sweep_draft or SweepDraft(self) + self.preparation_draft = preparation_draft or PreparationDraft(self) self.workspace: Workspace | None = None - self.document: DatasetConfigDocument | None = None + self.document: ConfigurationDocument | None = None self.capabilities: dict[str, Any] | None = None self._capability_cache: dict[str, dict[str, Any]] = {} self._session: RequestSession | None = None + self._request_context: _ConfigurationRequestContext | None = None + self._document_generation = 0 self._pending_action: str | None = None self._pending_path: Path | None = None self._pending_content: bytes | None = None @@ -85,16 +106,26 @@ def __init__( self._validation_attempted = False self._validation_content: bytes | None = None self._validation_sha256: str | None = None - self._file_display = "No dataset configuration is open." - self._status_message = "Open a workspace to create or import a dataset configuration." + self._file_display = "No configuration is open." + self._status_message = "Open a workspace to create or import a configuration." self._lifecycle_guard: Callable[[str], bool] | None = None self.dataset_draft.changed.connect(self._refresh_document) self.visualization_draft.changed.connect(self._refresh_document) + self.sweep_draft.changed.connect(self._refresh_document) + self.sweep_draft.validity_changed.connect(self._sweep_validity_changed) + self.preparation_draft.changed.connect(self._refresh_document) + self.preparation_draft.validity_changed.connect(self._preparation_validity_changed) self.dataset_draft.mode_change_requested.connect(self.mode_change_requested) self.dataset_draft.message.connect(self._set_status) self.visualization_draft.message.connect(self._set_status) self.visualization_draft.active_plot_draft_changed.connect(self._active_plot_edit_changed) + self.sweep_draft.active_comparison_draft_changed.connect(self._active_nested_edit_changed) + self.preparation_draft.active_scenario_draft_changed.connect( + self._active_nested_edit_changed + ) + self.sweep_draft.message.connect(self._set_status) + self.preparation_draft.message.connect(self._set_status) self.coordinator.busy_changed.connect(self._worker_busy_changed) def set_lifecycle_guard(self, guard: Callable[[str], bool]) -> None: @@ -112,6 +143,18 @@ def get_has_document(self) -> bool: hasDocument = Property(bool, get_has_document, notify=state_changed) + def get_document_kind(self) -> str: + document = self.document + return "none" if document is None else document.document_type + + documentKind = Property(str, get_document_kind, notify=state_changed) + + def get_reformat_required(self) -> bool: + document = self.document + return document is not None and document.imported + + reformatRequired = Property(bool, get_reformat_required, notify=state_changed) + def get_locally_valid(self) -> bool: return self._locally_valid @@ -119,7 +162,15 @@ def get_locally_valid(self) -> bool: def get_dirty(self) -> bool: document = self.document - return document is not None and ( + if document is None: + return False + if document.document_type == "model_sweep": + return document.needs_save or self.sweep_draft.get_dirty() + if document.document_type == "preparation": + return document.needs_save or self.preparation_draft.get_dirty() + if document.document_type != "dataset": + return document.needs_save + return ( document.needs_save or self.dataset_draft.get_dirty() or self.visualization_draft.get_dirty() @@ -141,7 +192,7 @@ def get_can_validate(self) -> bool: return ( self.document is not None and self._locally_valid - and not self.visualization_draft.get_has_active_plot_edit() + and not self._has_active_nested_edit() and self._pending_action is None and not self.coordinator.is_busy ) @@ -176,7 +227,17 @@ def get_worker_validation_issues(self) -> list[dict[str, str]]: ) def get_blocking_section(self) -> str: - if self.document is None or self._locally_valid: + if self.document is None: + return "none" + if self.document.document_type == "model_sweep" and ( + not self._locally_valid or self.sweep_draft.get_has_active_comparison_edit() + ): + return "sweep" + if self.document.document_type == "preparation" and ( + not self._locally_valid or self.preparation_draft.get_has_active_scenario_edit() + ): + return "preparation" + if self._locally_valid: return "none" if not self.dataset_draft.get_locally_valid(): return "dataset" @@ -188,6 +249,10 @@ def get_blocking_section(self) -> str: def get_blocking_field(self) -> str: section = self.get_blocking_section() + if section == "sweep": + return self.sweep_draft.get_first_invalid_field() + if section == "preparation": + return self.preparation_draft.get_first_invalid_field() if section == "dataset": return self.dataset_draft.get_first_invalid_field() if section == "visualization": @@ -198,6 +263,10 @@ def get_blocking_field(self) -> str: def get_blocking_row(self) -> int: section = self.get_blocking_section() + if section == "sweep": + return self.sweep_draft.get_first_invalid_row() + if section == "preparation": + return self.preparation_draft.get_first_invalid_row() if section == "dataset": return self.dataset_draft.get_first_invalid_row() if section == "visualization": @@ -208,6 +277,12 @@ def get_blocking_row(self) -> int: def get_blocking_issue(self) -> str: section = self.get_blocking_section() + if section == "sweep": + return self.sweep_draft.get_issue() + if section == "preparation": + if self.preparation_draft.get_has_active_scenario_edit(): + return self.preparation_draft.get_transient_edit_issue() + return self.preparation_draft.get_issue() if section == "dataset": return self.dataset_draft.get_issue() if section == "visualization": @@ -243,7 +318,13 @@ def get_status_message(self) -> str: def get_default_save_path(self) -> str: workspace = self.workspace - return "" if workspace is None else str(workspace.configs / "dataset.yaml") + if workspace is None: + return "" + filename = { + "model_sweep": "model-sweep.yaml", + "preparation": "preparation.yaml", + }.get(self.get_document_kind(), "dataset.yaml") + return str(workspace.configs / filename) defaultSavePath = Property(str, get_default_save_path, notify=state_changed) @@ -251,7 +332,7 @@ def get_can_create(self) -> bool: return ( self.get_editor_available() and not self.coordinator.is_busy - and not self.visualization_draft.get_has_active_plot_edit() + and not self._has_active_nested_edit() ) canCreate = Property(bool, get_can_create, notify=state_changed) @@ -260,15 +341,18 @@ def get_can_import(self) -> bool: return ( self.workspace is not None and not self.coordinator.is_busy - and not self.visualization_draft.get_has_active_plot_edit() + and not self._has_active_nested_edit() ) canImport = Property(bool, get_can_import, notify=state_changed) def get_can_edit(self) -> bool: - return self.get_editor_available() and ( - not self.coordinator.is_busy or self.coordinator.active_owner != "configuration" - ) + if not self.get_editor_available(): + return False + session = getattr(self.coordinator, "active_session", None) + if session is not None and session.owner in {"sweep", "preparation"}: + return bool(session.request_type == f"execute_{session.owner}") + return not self.coordinator.is_busy or self.coordinator.active_owner != "configuration" canEdit = Property(bool, get_can_edit, notify=state_changed) @@ -278,7 +362,7 @@ def get_can_save(self) -> bool: and self._locally_valid and self._pending_action is None and not self.coordinator.is_busy - and not self.visualization_draft.get_has_active_plot_edit() + and not self._has_active_nested_edit() ) canSave = Property(bool, get_can_save, notify=state_changed) @@ -297,8 +381,8 @@ def request_validation(self) -> bool: self._begin_worker_validation("validate", content) self._set_status("Validating the current exact YAML…") return self._start_worker( - "validate_dataset_config", - {"yaml_text": content.decode("utf-8"), "source_name": source_name}, + _validation_request_type(document.document_type), + _validation_payload(document.document_type, content, source_name), ) def get_dataset_draft(self) -> QObject: @@ -311,10 +395,22 @@ def get_visualization_draft(self) -> QObject: visualizationDraft = Property(QObject, get_visualization_draft, constant=True) + def get_sweep_draft(self) -> QObject: + return self.sweep_draft + + sweepDraft = Property(QObject, get_sweep_draft, constant=True) + + def get_preparation_draft(self) -> QObject: + return self.preparation_draft + + preparationDraft = Property(QObject, get_preparation_draft, constant=True) + def set_workspace(self, value: object) -> None: workspace = value if isinstance(value, Workspace) else None changed = self.workspace != workspace - if changed and not self._lifecycle_allowed("workspace replacement"): + if changed and ( + self._has_active_workflow_edit() or not self._lifecycle_allowed("workspace replacement") + ): return self.workspace = workspace if changed: @@ -339,12 +435,13 @@ def needs_discard_confirmation(self) -> bool: def new_dataset(self, mode: str, discard_confirmed: bool = False) -> bool: if not self._lifecycle_allowed("New Dataset"): return False - if self.workspace is None or self.capabilities is None: + capabilities = self.capabilities + if self.workspace is None or capabilities is None or self._has_active_workflow_edit(): return False if self.needs_discard_confirmation() and not discard_confirmed: self._set_status("Confirm discarding the current configuration before replacing it.") return False - modes = self.capabilities.get("modes") + modes = capabilities.get("modes") if not isinstance(modes, list) or mode not in modes: self._set_status(f"Unsupported dataset mode: {mode}") return False @@ -352,10 +449,32 @@ def new_dataset(self, mode: str, discard_confirmed: bool = False) -> bool: self._set_status("New configuration. Save it under the workspace configs folder.") return True + def new_sweep(self, discard_confirmed: bool = False) -> bool: + if not self._lifecycle_allowed("New Model Sweep") or not self.get_can_create(): + return False + if self.needs_discard_confirmation() and not discard_confirmed: + self._set_status("Confirm discarding the current configuration before replacing it.") + return False + self.open_document(new_document(_template_payload("model_sweep"))) + self._set_status("New model sweep. Save it under the workspace configs folder.") + return True + + def new_preparation(self, discard_confirmed: bool = False) -> bool: + """Create the portable Preparation document after composition guards pass.""" + + if not self._lifecycle_allowed("New ML Preparation") or not self.get_can_create(): + return False + if self.needs_discard_confirmation() and not discard_confirmed: + self._set_status("Confirm discarding the current configuration before replacing it.") + return False + self.open_document(new_document(_template_payload("preparation"))) + self._set_status("New ML Preparation. Save it under the workspace configs folder.") + return True + def import_dataset(self, path: str, discard_confirmed: bool = False) -> bool: if not self._lifecycle_allowed("Import"): return False - if self.workspace is None: + if self.workspace is None or self._has_active_workflow_edit(): return False if self.needs_discard_confirmation() and not discard_confirmed: self._set_status("Confirm discarding the current configuration before replacing it.") @@ -371,11 +490,37 @@ def import_dataset(self, path: str, discard_confirmed: bool = False) -> bool: {"config_path": str(self._pending_path)}, ) + def import_configuration(self, path: str, discard_confirmed: bool = False) -> bool: + """Open any current public configuration by its explicit discriminator.""" + + if not self._lifecycle_allowed("Open Configuration"): + return False + if not self.get_can_import(): + return False + if self.needs_discard_confirmation() and not discard_confirmed: + self._set_status("Confirm discarding the current configuration before replacing it.") + return False + candidate = path.strip() + if not candidate: + return False + self._pending_action = "import" + self._pending_path = Path(candidate).expanduser().resolve() + self._set_status("Validating imported configuration…") + return self._start_worker( + "load_configuration", + {"config_path": str(self._pending_path)}, + ) + def request_save(self, allow_reformat: bool = False) -> bool: if not self._lifecycle_allowed("Save"): return False document = self.document - if document is None or not self._locally_valid or self.coordinator.is_busy: + if ( + document is None + or not self._locally_valid + or self.coordinator.is_busy + or self._has_active_workflow_edit() + ): return False if document.source_path is None or not document.workspace_owned: return self._request_save_as(allow_reformat=allow_reformat) @@ -393,7 +538,12 @@ def request_save(self, allow_reformat: bool = False) -> bool: def request_save_as(self, allow_reformat: bool = False) -> bool: if not self._lifecycle_allowed("Save As"): return False - if self.document is None or not self._locally_valid or self.coordinator.is_busy: + if ( + self.document is None + or not self._locally_valid + or self.coordinator.is_busy + or self._has_active_workflow_edit() + ): return False return self._request_save_as(allow_reformat=allow_reformat) @@ -407,6 +557,8 @@ def save_path_selected(self, path: str) -> bool: if not self._lifecycle_allowed("Save As"): self._awaiting_save_path = False return False + if self._has_active_workflow_edit(): + return False if not self._awaiting_save_path: self._set_status("Save As is not awaiting a destination.") return False @@ -428,7 +580,12 @@ def reload_source(self, discard_confirmed: bool = False) -> bool: if not self._lifecycle_allowed("Reload"): return False document = self.document - if document is None or document.source_path is None or self.coordinator.is_busy: + if ( + document is None + or document.source_path is None + or self.coordinator.is_busy + or self._has_active_workflow_edit() + ): return False if self.needs_discard_confirmation() and not discard_confirmed: self._set_status("Confirm discarding local changes before reloading the source.") @@ -436,7 +593,7 @@ def reload_source(self, discard_confirmed: bool = False) -> bool: self._pending_action = "reload" self._set_status("Reloading configuration changed outside Carnopy…") return self._start_worker( - "load_dataset_config", + _load_request_type(document.document_type), {"config_path": str(document.source_path)}, ) @@ -444,7 +601,7 @@ def apply_mode_change(self, selected: str) -> bool: if not self._lifecycle_allowed("dataset mode change"): return False document = self.document - if document is None: + if document is None or document.document_type != "dataset": return False self._syncing_document = True changed = False @@ -465,48 +622,82 @@ def apply_mode_change(self, selected: str) -> bool: def apply_coordinate_change(self, selected: str) -> bool: if not self._lifecycle_allowed("dataset coordinate change"): return False - if self.document is None: + if self.document is None or self.document.document_type != "dataset": return False return self.dataset_draft.set_coordinate(selected) - def open_document(self, document: DatasetConfigDocument) -> bool: - if not self._lifecycle_allowed("document replacement"): + def open_document(self, document: ConfigurationDocument) -> bool: + if self._has_active_workflow_edit() or not self._lifecycle_allowed("document replacement"): return False + self._document_generation += 1 self.document = document self._reset_worker_validation("not_run") self._syncing_document = True try: payload = document.payload - self.dataset_draft.load_payload(payload) - self.visualization_draft.set_dataset_context(payload) - self.visualization_draft.load_visualization(payload.get("visualization")) + if document.document_type == "dataset": + self.dataset_draft.load_payload(payload) + self.visualization_draft.set_dataset_context(payload) + self.visualization_draft.load_visualization(payload.get("visualization")) + self.sweep_draft.clear() + self.preparation_draft.clear() + elif document.document_type == "model_sweep": + self.dataset_draft.clear() + self.visualization_draft.clear() + self.sweep_draft.load_payload(payload) + self.preparation_draft.clear() + else: + self.dataset_draft.clear() + self.visualization_draft.clear() + self.sweep_draft.clear() + self.preparation_draft.load_payload(payload) finally: self._syncing_document = False + label = document.document_type.replace("_", " ") self._file_display = ( str(document.source_path) if document.source_path is not None - else "Unsaved dataset configuration" + else f"Unsaved {label} configuration" ) self._refresh_document() - self.document_opened.emit() + self.configuration_document_opened.emit(document.document_type) return True def clear_document(self, discard_confirmed: bool = False) -> bool: - if not self._lifecycle_allowed("Close Configuration"): + if self._has_active_workflow_edit() or not self._lifecycle_allowed("Close Configuration"): return False if self.needs_discard_confirmation() and not discard_confirmed: self._set_status("Confirm discarding the current configuration before closing it.") return False self._clear_document() - self._set_status("Create a new dataset configuration or import a valid YAML file.") + self._set_status("Create a new configuration or open a valid YAML file.") return True - def execution_snapshot(self) -> SavedConfigSnapshot: + def execution_snapshot( + self, + *, + expected_document_type: DocumentType = "dataset", + ) -> SavedConfigSnapshot: if self.workspace is None or self.document is None: - raise ConfigDocumentError("open and save a dataset configuration before execution") - if not self._locally_valid or not ( - self.dataset_draft.get_locally_valid() and self.visualization_draft.get_locally_valid() - ): + raise ConfigDocumentError( + f"open and save a {expected_document_type} configuration before execution" + ) + if self.document.document_type != expected_document_type: + raise ConfigDocumentError( + f"the open configuration is {self.document.document_type}, not " + f"{expected_document_type}" + ) + drafts_valid = ( + self.sweep_draft.get_locally_valid() + if expected_document_type == "model_sweep" + else self.preparation_draft.get_locally_valid() + if expected_document_type == "preparation" + else ( + self.dataset_draft.get_locally_valid() + and self.visualization_draft.get_locally_valid() + ) + ) + if not self._locally_valid or not drafts_valid or self._has_active_workflow_edit(): raise ConfigDocumentError("complete the configuration form before execution") return self.document.execution_snapshot(configs_root=self.workspace.configs) @@ -539,8 +730,8 @@ def _validate_before_save(self, path: Path, *, replace: bool) -> None: self._pending_content = content self._set_status("Validating exact YAML before Save…") self._start_worker( - "validate_dataset_config", - {"yaml_text": content.decode("utf-8"), "source_name": str(path)}, + _validation_request_type(document.document_type), + _validation_payload(document.document_type, content, str(path)), ) def _start_worker( @@ -549,6 +740,7 @@ def _start_worker( payload: dict[str, object], ) -> bool: try: + context = self._capture_request_context(request_type, payload) session = self.coordinator.start_request( "configuration", request_type, @@ -569,6 +761,7 @@ def _start_worker( self.state_changed.emit() return False self._session = session + self._request_context = context session.completed.connect(self._worker_completed) self.state_changed.emit() return True @@ -578,14 +771,24 @@ def _worker_completed(self, value: object) -> None: session = self._session if session is None or outcome.request_id != session.request_id: return + context = self._request_context self._session = None + self._request_context = None + if context is None or not self._response_context_is_current(context): + self._discard_obsolete_response(context) + return result = outcome.result_payload if result is not None: - self._worker_succeeded(result) + self._worker_succeeded(result, context=context) return self._worker_failed(outcome.failure_payload or {}) - def _worker_succeeded(self, payload: object) -> None: + def _worker_succeeded( + self, + payload: object, + *, + context: _ConfigurationRequestContext, + ) -> None: result = cast(dict[str, Any], payload) action = self._pending_action if action is None: @@ -598,7 +801,11 @@ def _worker_succeeded(self, payload: object) -> None: self._capability_cache[model] = result self._apply_capabilities(result) elif action in {"import", "reload"}: - self._finish_import(result, operation=action) + self._finish_import( + result, + operation=action, + requested_path=context.requested_path, + ) elif action == "validate": self._clear_validation_request() if validation_current: @@ -665,33 +872,56 @@ def _active_plot_edit_changed(self) -> None: self._update_worker_validation(validation_revision_changed=True) self.state_changed.emit() + def _active_nested_edit_changed(self) -> None: + self._update_worker_validation(validation_revision_changed=True) + self.state_changed.emit() + + def _sweep_validity_changed(self) -> None: + if not self._syncing_document: + self.state_changed.emit() + + def _preparation_validity_changed(self) -> None: + if not self._syncing_document: + self.state_changed.emit() + def _apply_capabilities(self, payload: dict[str, Any]) -> None: self.capabilities = payload self.dataset_draft.apply_capabilities(payload) self.visualization_draft.apply_capabilities(payload) + self.sweep_draft.apply_capabilities(payload) + self.preparation_draft.apply_capabilities(payload) if self.document is not None: self._refresh_document() else: - self._set_status("Create a new dataset configuration or import a valid YAML file.") + self._set_status("Create a new configuration or open a valid YAML file.") self.state_changed.emit() def _clear_document(self) -> None: + self._document_generation += 1 self.document = None self._syncing_document = True try: self.dataset_draft.clear() self.visualization_draft.clear() + self.sweep_draft.clear() + self.preparation_draft.clear() finally: self._syncing_document = False self._locally_valid = False self._yaml_preview = "" self._reset_worker_validation("unavailable") - self._file_display = "No dataset configuration is open." + self._file_display = "No configuration is open." self._awaiting_save_path = False self._clear_pending() self._emit_document_state() - def _finish_import(self, payload: dict[str, Any], *, operation: str) -> None: + def _finish_import( + self, + payload: dict[str, Any], + *, + operation: str, + requested_path: Path | None, + ) -> None: workspace = self.workspace if workspace is None: return @@ -701,6 +931,11 @@ def _finish_import(self, payload: dict[str, Any], *, operation: str) -> None: self._set_status(str(exc)) self._emit_operation_failed(operation, _failure_title(operation), str(exc), []) return + if requested_path is None or document.source_path != requested_path: + message = "worker configuration result does not match its requested source" + self._set_status(message) + self._emit_operation_failed(operation, _failure_title(operation), message, []) + return if not self.open_document(document): return location = "workspace configuration" if document.workspace_owned else "external import" @@ -759,8 +994,13 @@ def _finish_save(self, *, replace: bool, validation_current: bool) -> None: ) return document.mark_saved(destination, content) - self.dataset_draft.mark_baseline() - self.visualization_draft.mark_baseline() + if document.document_type == "model_sweep": + self.sweep_draft.mark_baseline() + elif document.document_type == "preparation": + self.preparation_draft.mark_baseline() + elif document.document_type == "dataset": + self.dataset_draft.mark_baseline() + self.visualization_draft.mark_baseline() self._file_display = str(destination) self._refresh_document(validation_revision_changed=False) self._set_status(f"Saved valid configuration: {destination}") @@ -791,22 +1031,31 @@ def _refresh_document(self, *, validation_revision_changed: bool = True) -> None self._emit_document_state() return try: - payload = self.dataset_draft.merge_into(document.payload) - dataset_context = self.dataset_draft.dataset_payload() - self.visualization_draft.set_dataset_context(dataset_context) - if not self.visualization_draft.get_locally_valid(): - raise ValueError(self.visualization_draft.get_issue()) - visualization = self.visualization_draft.visualization_payload() - if visualization is None: - payload.pop("visualization", None) - else: - payload["visualization"] = visualization + if document.document_type == "model_sweep": + if not self.sweep_draft.get_locally_valid(): + raise ValueError(self.sweep_draft.get_issue()) + document.set_payload(self.sweep_draft.payload()) + elif document.document_type == "preparation": + if not self.preparation_draft.get_locally_valid(): + raise ValueError(self.preparation_draft.get_issue()) + document.set_payload(self.preparation_draft.payload()) + elif document.document_type == "dataset": + payload = self.dataset_draft.merge_into(document.payload) + dataset_context = self.dataset_draft.dataset_payload() + self.visualization_draft.set_dataset_context(dataset_context) + if not self.visualization_draft.get_locally_valid(): + raise ValueError(self.visualization_draft.get_issue()) + visualization = self.visualization_draft.visualization_payload() + if visualization is None: + payload.pop("visualization", None) + else: + payload["visualization"] = visualization + document.set_payload(payload) except ValueError as exc: self._locally_valid = False self._yaml_preview = "" self._set_status(str(exc)) else: - document.set_payload(payload) self._locally_valid = True self._yaml_preview = document.yaml_text self._set_status("Ready to save. Full validation runs before writing.") @@ -827,6 +1076,73 @@ def _clear_pending(self, *, keep_paths: bool = False) -> None: self._pending_path = None self._pending_content = None + def _capture_request_context( + self, + request_type: RequestType, + payload: dict[str, object], + ) -> _ConfigurationRequestContext: + document = self.document + requested = payload.get("config_path") + requested_path = ( + Path(requested).expanduser().resolve() if isinstance(requested, str) else None + ) + content = self._validation_content + return _ConfigurationRequestContext( + action=self._pending_action or request_type, + workspace_root=None if self.workspace is None else self.workspace.root, + document_generation=self._document_generation, + document_type=None if document is None else document.document_type, + document_sha256="" if content is None else sha256_bytes(content), + requested_path=requested_path, + ) + + def _response_context_is_current(self, context: _ConfigurationRequestContext) -> bool: + workspace_root = None if self.workspace is None else self.workspace.root + if workspace_root != context.workspace_root: + return False + if context.action == "capabilities": + return True + if self._document_generation != context.document_generation: + return False + if context.action == "import": + return self._pending_path == context.requested_path + document = self.document + if document is None or document.document_type != context.document_type: + return False + if context.action == "reload": + return ( + document.source_path == context.requested_path + and context.requested_path is not None + ) + if context.action in {"validate", "save_new", "save_replace"}: + content = self._validation_content + return ( + content is not None + and context.document_sha256 == sha256_bytes(content) + and context.document_sha256 == self._validation_sha256 + ) + return False + + def _discard_obsolete_response( + self, + context: _ConfigurationRequestContext | None, + ) -> None: + action = None if context is None else context.action + validation_action = action in {"validate", "save_new", "save_replace"} + self._clear_pending() + if validation_action: + was_running = self._worker_validation_state == "running" + self._clear_validation_request() + if action in {"save_new", "save_replace"}: + self._cancel_stale_save(replace=action == "save_replace") + elif was_running: + self._set_worker_validation( + "stale", + "The configuration context changed while worker validation was running.", + [], + ) + self.state_changed.emit() + def _begin_worker_validation(self, action: str, content: bytes) -> None: self._pending_action = action self._validation_attempted = True @@ -853,7 +1169,7 @@ def _validation_response_is_current(self) -> bool: or content is None or digest is None or not self._locally_valid - or self.visualization_draft.get_has_active_plot_edit() + or self._has_active_nested_edit() ): return False current = document.yaml_bytes @@ -863,10 +1179,10 @@ def _update_worker_validation(self, validation_revision_changed: bool) -> None: if self.document is None: self._set_worker_validation("unavailable", "", []) return - active_edit = self.visualization_draft.get_has_active_plot_edit() + active_edit = self._has_active_nested_edit() if not self._locally_valid or active_edit: issue = ( - "Commit or cancel the active plot edit before validation." + "Commit or cancel the active nested edit before validation." if active_edit else self.get_blocking_issue() ) @@ -929,6 +1245,17 @@ def _emit_document_state(self) -> None: def _lifecycle_allowed(self, operation: str) -> bool: return self._lifecycle_guard is None or self._lifecycle_guard(operation) + def _has_active_nested_edit(self) -> bool: + return ( + self.visualization_draft.get_has_active_plot_edit() or self._has_active_workflow_edit() + ) + + def _has_active_workflow_edit(self) -> bool: + return ( + self.sweep_draft.get_has_active_comparison_edit() + or self.preparation_draft.get_has_active_scenario_edit() + ) + def _template_payload(mode: str) -> dict[str, Any]: value = yaml.safe_load(template_text(cast(Any, mode))) @@ -942,7 +1269,9 @@ def _operation_name(action: str) -> str: "save_new": "save_as", "save_replace": "save", "load_dataset_config": "import", + "load_configuration": "import", "validate_dataset_config": "save", + "validate_configuration": "save", "describe_capabilities": "capabilities", }.get(action, action) @@ -959,6 +1288,28 @@ def _failure_title(action: str) -> str: }.get(operation, "Operation Failed") +def _load_request_type(document_type: DocumentType) -> RequestType: + return "load_dataset_config" if document_type == "dataset" else "load_configuration" + + +def _validation_request_type(document_type: DocumentType) -> RequestType: + return "validate_dataset_config" if document_type == "dataset" else "validate_configuration" + + +def _validation_payload( + document_type: DocumentType, + content: bytes, + source_name: str, +) -> dict[str, object]: + payload: dict[str, object] = { + "yaml_text": content.decode("utf-8"), + "source_name": source_name, + } + if document_type != "dataset": + payload["expected_document_type"] = document_type + return payload + + def _structured_issues(value: object) -> list[dict[str, str]]: if not isinstance(value, list): return [] diff --git a/src/carnopy/app/config_document.py b/src/carnopy/app/config_document.py index 0c44ac6..dbe7db8 100644 --- a/src/carnopy/app/config_document.py +++ b/src/carnopy/app/config_document.py @@ -8,7 +8,7 @@ from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path -from typing import Any, cast +from typing import Any, Literal, cast import yaml @@ -43,6 +43,32 @@ "x_scale": "linear", "y_scale": "linear", } +DocumentType = Literal["dataset", "model_sweep", "preparation"] + +SWEEP_PLOT_FIELD_ORDER = ( + "name", + "kind", + "fluid", + "property", + "x", + "group_by", + "filters", + "models", + "delta_metric", + "value_scale", + "format", +) +SCENARIO_FIELD_ORDER = ( + "name", + "kind", + "seed", + "partitions", + "field", + "holdouts", + "remainder", + "strata", + "transformations", +) class ConfigDocumentError(ValueError): @@ -60,10 +86,11 @@ class SavedConfigSnapshot: path: Path yaml_bytes: bytes sha256: str + document_type: DocumentType @dataclass -class DatasetConfigDocument: +class ConfigurationDocument: _payload: dict[str, Any] source_path: Path | None = None source_sha256: str | None = None @@ -73,7 +100,11 @@ class DatasetConfigDocument: def __post_init__(self) -> None: self._payload = copy.deepcopy(self._payload) - self._baseline_yaml = serialize_dataset_config(self._payload) + self._baseline_yaml = serialize_configuration(self._payload) + + @property + def document_type(self) -> DocumentType: + return document_type_from_payload(self._payload) @property def payload(self) -> dict[str, Any]: @@ -81,7 +112,7 @@ def payload(self) -> dict[str, Any]: @property def yaml_bytes(self) -> bytes: - return serialize_dataset_config(self._payload) + return serialize_configuration(self._payload) @property def yaml_text(self) -> str: @@ -126,7 +157,30 @@ def execution_snapshot(self, *, configs_root: Path) -> SavedConfigSnapshot: raise ExternalModificationError( f"saved configuration bytes no longer match the open document: {path}" ) - return SavedConfigSnapshot(path=path, yaml_bytes=content, sha256=digest) + return SavedConfigSnapshot( + path=path, + yaml_bytes=content, + sha256=digest, + document_type=self.document_type, + ) + + +def document_type_from_payload(payload: dict[str, Any]) -> DocumentType: + value = payload.get("document_type") + if value not in {"dataset", "model_sweep", "preparation"}: + raise ConfigDocumentError( + "configuration document_type must be dataset, model_sweep, or preparation" + ) + return cast(DocumentType, value) + + +def serialize_configuration(payload: dict[str, Any]) -> bytes: + document_type = document_type_from_payload(payload) + if document_type == "dataset": + return serialize_dataset_config(payload) + if document_type == "model_sweep": + return serialize_sweep_config(payload) + return serialize_preparation_config(payload) def serialize_dataset_config(payload: dict[str, Any]) -> bytes: @@ -170,33 +224,115 @@ def serialize_dataset_config(payload: dict[str, Any]) -> bytes: if visualization is not None: ordered["visualization"] = visualization - text = cast( - str, - yaml.safe_dump( - ordered, - allow_unicode=True, - default_flow_style=False, - sort_keys=False, - width=100, + return _dump_yaml(ordered) + + +def serialize_sweep_config(payload: dict[str, Any]) -> bytes: + """Serialize one valid model-sweep payload in deterministic public order.""" + + from carnopy.config.sweep import ModelSweepConfig + + model = ModelSweepConfig.model_validate(payload) + value = model.model_dump(mode="json", by_alias=True, exclude_none=True) + ordered: dict[str, Any] = { + "schema_version": value["schema_version"], + "document_type": value["document_type"], + "backend": _ordered_mapping(value["backend"], ("name", "models", "reference_model")), + "mode": value["mode"], + "fluids": copy.deepcopy(value["fluids"]), + "grid": _ordered_grid(cast(dict[str, Any], value["grid"])), + "properties": copy.deepcopy(value["properties"]), + "outputs": _ordered_mapping(value["outputs"], ("dataset_formats",)), + } + comparisons = value.get("comparison_plots") + if isinstance(comparisons, dict): + plots = comparisons.get("plots") + ordered_comparisons: dict[str, Any] = {} + if comparisons.get("format") not in (None, "png"): + ordered_comparisons["format"] = comparisons["format"] + if isinstance(plots, list): + ordered_comparisons["plots"] = [ + _ordered_mapping(plot, SWEEP_PLOT_FIELD_ORDER) + for plot in plots + if isinstance(plot, dict) + ] + if ordered_comparisons: + ordered["comparison_plots"] = ordered_comparisons + return _dump_yaml(ordered) + + +def serialize_preparation_config(payload: dict[str, Any]) -> bytes: + """Serialize one valid preparation payload in deterministic public order.""" + + from carnopy.preparation.models import PreparationConfig + + model = PreparationConfig.model_validate(payload) + value = model.model_dump(mode="json", by_alias=True, exclude_none=True) + features = cast(dict[str, Any], value["features"]) + ordered: dict[str, Any] = { + "schema_version": value["schema_version"], + "document_type": value["document_type"], + "source_policy": _ordered_mapping( + cast(dict[str, Any], value["source_policy"]), + ("allow_partial_sweep",), ), - ) - return text.encode("utf-8") + "features": _ordered_mapping(features, ("numeric", "derived")), + "categorical_features": [ + _ordered_mapping(item, ("field", "encoding", "categories")) + for item in cast(list[dict[str, Any]], value["categorical_features"]) + ], + "targets": copy.deepcopy(value["targets"]), + "auxiliary": copy.deepcopy(value["auxiliary"]), + } + scenarios = cast(list[dict[str, Any]], value["scenarios"]) + if scenarios: + ordered["scenarios"] = [_ordered_scenario(item) for item in scenarios] + quality = cast(dict[str, Any], value["quality"]) + ordered_quality: dict[str, Any] = {} + matrix = quality.get("matrix_diagnostics") + if isinstance(matrix, dict): + ordered_quality["matrix_diagnostics"] = _ordered_mapping( + matrix, + ("correlation_threshold", "near_constant_relative_spread"), + ) + baseline = quality.get("baseline_diagnostics") + if isinstance(baseline, dict): + ordered_quality["baseline_diagnostics"] = _ordered_mapping( + baseline, + ("models", "random_seed", "ridge_alpha", "histogram_max_iterations"), + ) + if ordered_quality: + ordered["quality"] = ordered_quality + outputs = cast(dict[str, Any], value["outputs"]) + ordered_outputs = _ordered_mapping(outputs, ("formats", "parquet")) + arrays = outputs.get("arrays") + if isinstance(arrays, dict): + ordered_outputs["arrays"] = _ordered_mapping( + arrays, + ("formats", "dtype", "include_auxiliary"), + ) + ordered["outputs"] = ordered_outputs + return _dump_yaml(ordered) def document_from_worker_payload( worker_payload: dict[str, Any], *, configs_root: Path, -) -> DatasetConfigDocument: +) -> ConfigurationDocument: config = worker_payload.get("config") source_name = worker_payload.get("source_name") source_sha256 = worker_payload.get("source_sha256") if not isinstance(config, dict): - raise ConfigDocumentError("worker response does not contain a dataset configuration") + raise ConfigDocumentError("worker response does not contain a configuration") if not isinstance(source_name, str) or not isinstance(source_sha256, str): raise ConfigDocumentError("worker response does not contain source identity") source_path = Path(source_name).expanduser().resolve() - return DatasetConfigDocument( + response_type = worker_payload.get("document_type", config.get("document_type")) + if response_type != config.get("document_type"): + raise ConfigDocumentError("worker response document type is inconsistent") + document_type_from_payload(config) + return ConfigurationDocument( config, source_path=source_path, source_sha256=source_sha256, @@ -205,8 +341,8 @@ def document_from_worker_payload( ) -def new_document(payload: dict[str, Any]) -> DatasetConfigDocument: - return DatasetConfigDocument(payload) +def new_document(payload: dict[str, Any]) -> ConfigurationDocument: + return ConfigurationDocument(payload) def write_new_config(path: Path, content: bytes, *, configs_root: Path) -> Path: @@ -341,6 +477,46 @@ def _ordered_visualization(value: object) -> dict[str, Any] | None: return ordered or None +def _ordered_grid(value: dict[str, Any]) -> dict[str, Any]: + ordered: dict[str, Any] = {} + for axis in GRID_AXIS_ORDER: + sampler = value.get(axis) + if not isinstance(sampler, dict): + continue + field_order = SAMPLER_FIELD_ORDER.get(str(sampler.get("kind")), tuple(sampler)) + ordered[axis] = _ordered_mapping(sampler, field_order) + return ordered + + +def _ordered_scenario(value: dict[str, Any]) -> dict[str, Any]: + ordered = _ordered_mapping(value, SCENARIO_FIELD_ORDER) + strata = ordered.get("strata") + if isinstance(strata, dict): + ordered["strata"] = _ordered_mapping(strata, ("categorical", "numeric_bins")) + transformations = ordered.get("transformations") + if isinstance(transformations, list): + ordered["transformations"] = [ + _ordered_mapping(item, ("field", "methods")) + for item in transformations + if isinstance(item, dict) + ] + return ordered + + +def _dump_yaml(value: dict[str, Any]) -> bytes: + text = cast( + str, + yaml.safe_dump( + value, + allow_unicode=True, + default_flow_style=False, + sort_keys=False, + width=100, + ), + ) + return text.encode("utf-8") + + def _ordered_plot(plot: dict[str, Any]) -> dict[str, Any]: ordered: dict[str, Any] = {} for key in PLOT_FIELD_ORDER: diff --git a/src/carnopy/app/configured_plot_results_controller.py b/src/carnopy/app/configured_plot_results_controller.py index 9f88795..076ecd4 100644 --- a/src/carnopy/app/configured_plot_results_controller.py +++ b/src/carnopy/app/configured_plot_results_controller.py @@ -8,7 +8,7 @@ from PySide6.QtGui import QDesktopServices from carnopy.app.activity_controller import ActivityController -from carnopy.app.config_controller import DatasetConfigController +from carnopy.app.config_controller import ConfigurationController from carnopy.app.inspection_models import InspectionListModel from carnopy.app.plot_artifacts import ( PlotArtifactError, @@ -55,7 +55,7 @@ class ConfiguredPlotResultsController(QObject): def __init__( self, activity: ActivityController, - configuration: DatasetConfigController, + configuration: ConfigurationController, previews: VerifiedPlotPreviewRegistry, parent: QObject | None = None, ) -> None: diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index c81dca7..9a9e20d 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -6,7 +6,8 @@ from carnopy.app.activity_controller import ActivityController from carnopy.app.client import WorkerClient -from carnopy.app.config_controller import DatasetConfigController +from carnopy.app.comparison_plot_draft import ComparisonPlotDraft +from carnopy.app.config_controller import ConfigurationController from carnopy.app.configured_plot_results_controller import ConfiguredPlotResultsController from carnopy.app.dataset_draft import DatasetDraft from carnopy.app.execution_controller import DatasetExecutionController @@ -18,6 +19,7 @@ from carnopy.app.qml_settings import QmlSettingsController from carnopy.app.request_coordinator import DesktopRequestCoordinator from carnopy.app.sampler_draft import SamplerDraft +from carnopy.app.scenario_draft import ScenarioDraft from carnopy.app.session_plot_controller import SessionPlotController from carnopy.app.visualization_draft import VisualizationDraft from carnopy.app.workflow_controller import ( @@ -37,7 +39,7 @@ class DesktopController(QObject): workspaceConfirmationRequested = Signal() datasetDecisionRequested = Signal() datasetDecisionChanged = Signal() - datasetDocumentOpened = Signal() + configurationDocumentOpened = Signal(str) attentionRequested = Signal(str, str, int) shutdownConfirmationRequested = Signal() transientEditShutdownConfirmationRequested = Signal(str) @@ -45,6 +47,7 @@ class DesktopController(QObject): navigationRequested = Signal(str, str) activityActionFailed = Signal(str, str) busyShutdownConfirmationRequested = Signal(str, str) + preparationSourceClearConfirmationRequested = Signal() def __init__( self, @@ -59,7 +62,7 @@ def __init__( self.request_coordinator = DesktopRequestCoordinator(self.client, self) self.dataset_draft = DatasetDraft(self) self.visualization_draft = VisualizationDraft(self) - self.dataset_config_controller = DatasetConfigController( + self.configuration_controller = ConfigurationController( self.request_coordinator, self.dataset_draft, self.visualization_draft, @@ -67,7 +70,7 @@ def __init__( ) self.execution_controller = DatasetExecutionController( self.request_coordinator, - self.dataset_config_controller, + self.configuration_controller, self, ) self.inspection_controller = InspectionController( @@ -77,11 +80,16 @@ def __init__( self.sweep_workflow_controller = SweepWorkflowController( self.request_coordinator, self, + configuration_controller=self.configuration_controller, ) self.preparation_workflow_controller = PreparationWorkflowController( self.request_coordinator, self.inspection_controller, self, + configuration_controller=self.configuration_controller, + ) + self.preparation_workflow_controller.source_binding_changed.connect( + self._preparation_source_binding_changed ) self.activity_controller = ActivityController( self.request_coordinator, @@ -90,7 +98,7 @@ def __init__( self.plot_preview_registry = VerifiedPlotPreviewRegistry(self) self.configured_plot_results_controller = ConfiguredPlotResultsController( self.activity_controller, - self.dataset_config_controller, + self.configuration_controller, self.plot_preview_registry, self, ) @@ -113,6 +121,10 @@ def __init__( self.activity_controller.refresh_records ) self.execution_controller.state_changed.connect(self._continue_pending_busy_shutdown) + self.sweep_workflow_controller.state_changed.connect(self._continue_pending_busy_shutdown) + self.preparation_workflow_controller.state_changed.connect( + self._continue_pending_busy_shutdown + ) self.execution_controller.run_finalized.connect( lambda _path: self.inspection_controller.refresh_sources() ) @@ -128,7 +140,7 @@ def __init__( self.preparation_workflow_controller.output_finalized.connect( lambda _path: self.inspection_controller.refresh_sources() ) - self.dataset_config_controller.set_lifecycle_guard(self._guard_active_plot_edit) + self.configuration_controller.set_lifecycle_guard(self._guard_configuration_lifecycle) self.workspace_controller = WorkspaceController( self.request_coordinator, self.settings, @@ -142,8 +154,19 @@ def __init__( self.workspace_controller.pending_operation_changed.connect( self.workspace_confirmation_changed ) - self.dataset_config_controller.state_changed.connect(self._configuration_state_changed) - self.dataset_config_controller.document_opened.connect(self.datasetDocumentOpened) + self._configuration_state_revision = 0 + self._pending_busy_shutdown = "" + self._pending_busy_confirmation_session: object | None = None + self._pending_busy_shutdown_session: object | None = None + self._pending_transient_shutdown_context: tuple[tuple[bool, object | None], ...] | None = ( + None + ) + self._pending_discard_shutdown_context: tuple[object | None, int] | None = None + self._approved_discard_shutdown_context: tuple[object | None, int] | None = None + self.configuration_controller.state_changed.connect(self._configuration_state_changed) + self.configuration_controller.configuration_document_opened.connect( + self.configurationDocumentOpened + ) self.request_coordinator.busy_changed.connect(self._request_state_changed) self.visualization_draft.active_plot_draft_changed.connect(self._active_plot_state_changed) self.session_plot_controller.active_edit_changed.connect(self._active_plot_state_changed) @@ -158,9 +181,7 @@ def __init__( self._workspace_request_timer.setInterval(0) self._workspace_request_timer.timeout.connect(self._run_queued_workspace_request) self._shutdown = False - self._shutdown_discard_confirmed = False self._pending_explore_source: Path | None = None - self._pending_busy_shutdown = "" def get_workspace_controller(self) -> QObject: return self.workspace_controller @@ -179,10 +200,10 @@ def get_workspace_available(self) -> bool: def get_workspace_state(self) -> str: if not self.workspace_controller.get_available(): return "unavailable" - if self.dataset_config_controller.get_has_document(): + if self.configuration_controller.get_has_document(): return "editing" if ( - not self.dataset_config_controller.get_editor_available() + not self.configuration_controller.get_editor_available() and self.request_coordinator.is_busy and self.request_coordinator.active_owner == "configuration" ): @@ -223,6 +244,8 @@ def get_can_change_workspace(self) -> bool: self.workspace_controller.get_can_change_workspace() and self.visualization_draft.get_active_plot_draft() is None and not self.session_plot_controller.get_has_active_edit() + and not self.get_has_active_sweep_edit() + and not self.get_has_active_preparation_edit() ) canChangeWorkspace = Property( @@ -267,7 +290,7 @@ def get_workspace_confirmation_required(self) -> bool: and self.workspace_controller.get_pending_path() == str(workspace.root) ): return False - return self.dataset_config_controller.needs_discard_confirmation() + return self.configuration_controller.needs_discard_confirmation() workspaceConfirmationRequired = Property( bool, @@ -289,7 +312,7 @@ def get_workspace_confirmation_title(self) -> str: def get_workspace_confirmation_message(self) -> str: operation = self.workspace_controller.get_pending_operation() path = self.workspace_controller.get_pending_path() - dirty = self.dataset_config_controller.needs_discard_confirmation() + dirty = self.configuration_controller.needs_discard_confirmation() if operation == "initialize_existing": message = f"Initialize this existing folder as a Carnopy workspace?\n\n{path}" if dirty: @@ -349,8 +372,31 @@ def get_has_session_plot_edit(self) -> bool: notify=workspace_state_changed, ) + def get_has_active_sweep_edit(self) -> bool: + return self.configuration_controller.sweep_draft.get_has_active_comparison_edit() + + hasActiveSweepEdit = Property( + bool, + get_has_active_sweep_edit, + notify=workspace_state_changed, + ) + + def get_has_active_preparation_edit(self) -> bool: + return self.configuration_controller.preparation_draft.get_has_active_scenario_edit() + + hasActivePreparationEdit = Property( + bool, + get_has_active_preparation_edit, + notify=workspace_state_changed, + ) + def get_has_any_transient_edit(self) -> bool: - return self.get_has_active_plot_edit() or self.get_has_session_plot_edit() + return ( + self.get_has_active_plot_edit() + or self.get_has_session_plot_edit() + or self.get_has_active_sweep_edit() + or self.get_has_active_preparation_edit() + ) hasAnyTransientEdit = Property( bool, @@ -358,12 +404,12 @@ def get_has_any_transient_edit(self) -> bool: notify=workspace_state_changed, ) - def get_dataset_config_controller(self) -> QObject: - return self.dataset_config_controller + def get_configuration_controller(self) -> QObject: + return self.configuration_controller - datasetConfigController = Property( + configurationController = Property( QObject, - get_dataset_config_controller, + get_configuration_controller, constant=True, ) @@ -376,6 +422,24 @@ def get_execution_controller(self) -> QObject: constant=True, ) + def get_sweep_workflow_controller(self) -> QObject: + return self.sweep_workflow_controller + + sweepWorkflowController = Property( + QObject, + get_sweep_workflow_controller, + constant=True, + ) + + def get_preparation_workflow_controller(self) -> QObject: + return self.preparation_workflow_controller + + preparationWorkflowController = Property( + QObject, + get_preparation_workflow_controller, + constant=True, + ) + def get_inspection_controller(self) -> QObject: return self.inspection_controller @@ -447,43 +511,72 @@ def get_dataset_decision_message(self) -> str: @Slot(str, bool, result=bool, name="requestNewDataset") def request_new_dataset(self, mode: str, discard_confirmed: bool = False) -> bool: - if not self._guard_active_plot_edit("New Dataset"): + if not self._guard_idle_configuration_action("New Dataset"): + return False + return self.configuration_controller.new_dataset(mode, discard_confirmed) + + @Slot(bool, result=bool, name="requestNewSweep") + def request_new_sweep(self, discard_confirmed: bool = False) -> bool: + if not self._guard_idle_configuration_action("New Model Sweep"): return False - return self.dataset_config_controller.new_dataset(mode, discard_confirmed) + return self.configuration_controller.new_sweep(discard_confirmed) + + @Slot(bool, result=bool, name="requestNewPreparation") + def request_new_preparation(self, discard_confirmed: bool = False) -> bool: + if not self._guard_idle_configuration_action("New ML Preparation"): + return False + return self.configuration_controller.new_preparation(discard_confirmed) + + @Slot(str, bool, result=bool, name="requestImportConfiguration") + def request_import_configuration( + self, + path: str, + discard_confirmed: bool = False, + ) -> bool: + if not self._guard_idle_configuration_action("Open Configuration"): + return False + return self.configuration_controller.import_configuration( + _local_path(path), + discard_confirmed, + ) @Slot(str, bool, result=bool, name="requestImportDataset") def request_import_dataset(self, path: str, discard_confirmed: bool = False) -> bool: - if not self._guard_active_plot_edit("Import"): + if not self._guard_idle_configuration_action("Import"): return False - return self.dataset_config_controller.import_dataset( + return self.configuration_controller.import_dataset( _local_path(path), discard_confirmed, ) @Slot(bool, result=bool, name="requestSave") def request_save(self, allow_reformat: bool = False) -> bool: - if not self._guard_active_plot_edit("Save"): + if not self._guard_idle_configuration_action("Save"): return False - return self.dataset_config_controller.request_save(allow_reformat) + return self.configuration_controller.request_save(allow_reformat) @Slot(bool, result=bool, name="requestSaveAs") def request_save_as(self, allow_reformat: bool = False) -> bool: - if not self._guard_active_plot_edit("Save As"): + if not self._guard_idle_configuration_action("Save As"): return False - return self.dataset_config_controller.request_save_as(allow_reformat) + return self.configuration_controller.request_save_as(allow_reformat) @Slot(result=bool, name="requestValidateConfiguration") def request_validate_configuration(self) -> bool: - if not self._guard_active_plot_edit("Validation"): + if not self._guard_idle_configuration_action("Validation"): return False - return self.dataset_config_controller.request_validation() + return self.configuration_controller.request_validation() @Slot(result=bool, name="requestExecutionValidation") def request_execution_validation(self) -> bool: + if not self._guard_idle_configuration_action("Dataset validation"): + return False return self.execution_controller.validate() @Slot(result=bool, name="requestDatasetGeneration") def request_dataset_generation(self) -> bool: + if not self._guard_idle_configuration_action("Dataset generation"): + return False return self.execution_controller.generate() @Slot(result=bool, name="requestExecutionCancel") @@ -494,6 +587,46 @@ def request_execution_cancel(self) -> bool: def request_execution_force_stop(self) -> bool: return self.execution_controller.force_stop() + @Slot(str, result=bool, name="requestWorkflowPlan") + def request_workflow_plan(self, workflow: str) -> bool: + controller = self._workflow_controller(workflow) + if controller is None or not self._guard_idle_configuration_action( + f"{_workflow_label(workflow)} planning" + ): + return False + return controller.plan() + + @Slot(str, result=bool, name="requestWorkflowExecute") + def request_workflow_execute(self, workflow: str) -> bool: + controller = self._workflow_controller(workflow) + if controller is None or not self._guard_idle_configuration_action( + f"{_workflow_label(workflow)} execution" + ): + return False + return controller.execute() + + @Slot(str, result=bool, name="requestWorkflowCancel") + def request_workflow_cancel(self, workflow: str) -> bool: + controller = self._workflow_controller(workflow) + return False if controller is None else controller.cancel() + + @Slot(str, result=bool, name="requestWorkflowForceStop") + def request_workflow_force_stop(self, workflow: str) -> bool: + controller = self._workflow_controller(workflow) + return False if controller is None else controller.force_stop() + + @Slot(str, result=bool, name="requestWorkflowInspectResult") + def request_workflow_inspect_result(self, workflow: str) -> bool: + controller = self._workflow_controller(workflow) + output = "" if controller is None else controller.get_result_output_directory() + if not output: + self.activityActionFailed.emit( + "Inspect Result", + "Complete this workflow successfully before inspecting its finalized output.", + ) + return False + return self._inspect_run(output, navigate=True) + @Slot(str, result=bool, name="requestInspectSource") def request_inspect_source(self, source: str) -> bool: return self.inspection_controller.inspect_source(_local_path(source)) @@ -502,6 +635,18 @@ def request_inspect_source(self, source: str) -> bool: def request_refresh_inspection(self) -> bool: return self.inspection_controller.refresh_inspection() + @Slot(result=bool, name="requestBindInspectedPreparationSource") + def request_bind_inspected_preparation_source(self) -> bool: + return self.preparation_workflow_controller.bind_inspected_source() + + @Slot(bool, result=bool, name="requestClearPreparationSource") + def request_clear_preparation_source(self, confirmed: bool = False) -> bool: + controller = self.preparation_workflow_controller + if controller.get_has_bound_source() and controller.get_plan_current() and not confirmed: + self.preparationSourceClearConfirmationRequested.emit() + return False + return controller.clear_bound_source() + @Slot(name="requestRefreshInspectionSources") def request_refresh_inspection_sources(self) -> None: self.inspection_controller.refresh_sources() @@ -603,34 +748,35 @@ def request_activity_recovery_removal(self) -> bool: @Slot(str, result=bool, name="requestSavePathSelected") def request_save_path_selected(self, path: str) -> bool: - if not self._guard_active_plot_edit("Save As"): + if not self._guard_idle_configuration_action("Save As"): + self.configuration_controller.cancel_save_path() return False - return self.dataset_config_controller.save_path_selected(_local_path(path)) + return self.configuration_controller.save_path_selected(_local_path(path)) @Slot(name="requestCancelSavePath") def request_cancel_save_path(self) -> None: - self.dataset_config_controller.cancel_save_path() + self.configuration_controller.cancel_save_path() @Slot(str, name="requestConfirmReformat") def request_confirm_reformat(self, action: str) -> None: - if self._guard_active_plot_edit("Save"): - self.dataset_config_controller.confirm_reformat(action) + if self._guard_idle_configuration_action("Save"): + self.configuration_controller.confirm_reformat(action) @Slot(bool, result=bool, name="requestReloadSource") def request_reload_source(self, discard_confirmed: bool = False) -> bool: - if not self._guard_active_plot_edit("Reload"): + if not self._guard_idle_configuration_action("Reload"): return False - return self.dataset_config_controller.reload_source(discard_confirmed) + return self.configuration_controller.reload_source(discard_confirmed) @Slot(bool, result=bool, name="requestCloseConfiguration") def request_close_configuration(self, discard_confirmed: bool = False) -> bool: - if not self._guard_active_plot_edit("Close Configuration"): + if not self._guard_idle_configuration_action("Close Configuration"): return False - return self.dataset_config_controller.clear_document(discard_confirmed) + return self.configuration_controller.clear_document(discard_confirmed) @Slot(str, str, int, result=bool, name="requestConfigurationAttention") def request_configuration_attention(self, section: str, field: str, row: int) -> bool: - if section not in {"dataset", "visualization"}: + if section not in {"dataset", "sweep", "preparation", "visualization"}: return False if not field.startswith(f"{section}.") and not ( section == "visualization" and field.startswith("plot.") @@ -641,7 +787,9 @@ def request_configuration_attention(self, section: str, field: str, row: int) -> @Slot(str, result=bool, name="requestDatasetModeChange") def request_dataset_mode_change(self, mode: str) -> bool: - if not self._guard_active_plot_edit("dataset mode change"): + if not self._can_edit_dataset_document() or not self._guard_configuration_lifecycle( + "dataset mode change" + ): return False if mode == self.dataset_draft.get_mode_name(): return False @@ -653,7 +801,9 @@ def request_dataset_mode_change(self, mode: str) -> bool: @Slot(str, result=bool, name="requestDatasetCoordinateChange") def request_dataset_coordinate_change(self, axis: str) -> bool: - if not self._guard_active_plot_edit("dataset coordinate change"): + if not self._can_edit_dataset_document() or not self._guard_configuration_lifecycle( + "dataset coordinate change" + ): return False if axis == self.dataset_draft.get_coordinate_name(): return False @@ -668,16 +818,20 @@ def commit_dataset_decision(self, confirmed: bool) -> bool: decision = self._pending_dataset_decision if decision is None: return False - if not confirmed or not self._guard_active_plot_edit("dataset replacement"): + if ( + not confirmed + or not self._can_edit_dataset_document() + or not self._guard_configuration_lifecycle("dataset replacement") + ): self._pending_dataset_decision = None self.datasetDecisionChanged.emit() return False self._pending_dataset_decision = None operation, value = decision if operation == "mode": - changed = self.dataset_config_controller.apply_mode_change(value) + changed = self.configuration_controller.apply_mode_change(value) else: - changed = self.dataset_config_controller.apply_coordinate_change(value) + changed = self.configuration_controller.apply_coordinate_change(value) self.datasetDecisionChanged.emit() return changed @@ -688,10 +842,13 @@ def cancel_dataset_decision(self) -> None: @Slot(str, name="requestDatasetModelChange") def request_dataset_model_change(self, model: str) -> None: - self.dataset_draft.set_model_name(model) + if self._can_edit_dataset_document(): + self.dataset_draft.set_model_name(model) @Slot(str, bool, name="requestDatasetFluidSelection") def request_dataset_fluid_selection(self, value: str, selected: bool) -> None: + if not self._can_edit_dataset_document(): + return if selected: self.dataset_draft.add_fluid(value) else: @@ -699,14 +856,18 @@ def request_dataset_fluid_selection(self, value: str, selected: bool) -> None: @Slot(int, int, name="requestDatasetFluidMove") def request_dataset_fluid_move(self, row: int, offset: int) -> None: - self.dataset_draft.move_fluid(row, offset) + if self._can_edit_dataset_document(): + self.dataset_draft.move_fluid(row, offset) @Slot(int, name="requestDatasetFluidRemove") def request_dataset_fluid_remove(self, row: int) -> None: - self.dataset_draft.remove_fluid(row) + if self._can_edit_dataset_document(): + self.dataset_draft.remove_fluid(row) @Slot(str, bool, name="requestDatasetPropertySelection") def request_dataset_property_selection(self, value: str, selected: bool) -> None: + if not self._can_edit_dataset_document(): + return if selected: self.dataset_draft.add_property(value) else: @@ -714,20 +875,23 @@ def request_dataset_property_selection(self, value: str, selected: bool) -> None @Slot(int, int, name="requestDatasetPropertyMove") def request_dataset_property_move(self, row: int, offset: int) -> None: - self.dataset_draft.move_property(row, offset) + if self._can_edit_dataset_document(): + self.dataset_draft.move_property(row, offset) @Slot(int, name="requestDatasetPropertyRemove") def request_dataset_property_remove(self, row: int) -> None: - self.dataset_draft.remove_property(row) + if self._can_edit_dataset_document(): + self.dataset_draft.remove_property(row) @Slot(str, bool, name="requestDatasetOutputSelection") def request_dataset_output_selection(self, output_format: str, selected: bool) -> None: - self.dataset_draft.set_output_selected(output_format, selected) + if self._can_edit_dataset_document(): + self.dataset_draft.set_output_selected(output_format, selected) @Slot(QObject, str, name="requestDatasetSamplerKindChange") def request_dataset_sampler_kind_change(self, candidate: QObject, kind: str) -> None: sampler = self._owned_dataset_sampler(candidate) - if sampler is not None: + if sampler is not None and self._can_edit_dataset_document(): sampler.set_kind(kind) @Slot(QObject, str, str, name="requestDatasetSamplerTextChange") @@ -738,39 +902,556 @@ def request_dataset_sampler_text_change( text: str, ) -> None: sampler = self._owned_dataset_sampler(candidate) - if sampler is not None: + if sampler is not None and self._can_edit_dataset_document(): sampler.set_text(field, text) @Slot(QObject, str, name="requestDatasetSamplerUnitChange") def request_dataset_sampler_unit_change(self, candidate: QObject, unit: str) -> None: sampler = self._owned_dataset_sampler(candidate) - if sampler is not None: + if sampler is not None and self._can_edit_dataset_document(): sampler.requestUnitChange(unit) + @Slot(str, bool, name="requestSweepModelSelection") + def request_sweep_model_selection(self, model: str, selected: bool) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.set_model_selected(model, selected) + + @Slot(str, name="requestSweepReferenceModel") + def request_sweep_reference_model(self, model: str) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.set_reference_model(model) + + @Slot(str, bool, name="requestSweepModeChange") + def request_sweep_mode_change(self, mode: str, confirmed: bool) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.apply_mode_change(mode, confirmed) + + @Slot(str, bool, name="requestSweepCoordinateChange") + def request_sweep_coordinate_change(self, axis: str, confirmed: bool) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.apply_coordinate_change(axis, confirmed) + + @Slot(str, bool, name="requestSweepFluidSelection") + def request_sweep_fluid_selection(self, value: str, selected: bool) -> None: + if not self._can_edit_sweep_document(): + return + draft = self.configuration_controller.sweep_draft.dataset_draft + if selected: + draft.add_fluid(value) + else: + draft.remove_fluid_value(value) + + @Slot(int, int, name="requestSweepFluidMove") + def request_sweep_fluid_move(self, row: int, offset: int) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.dataset_draft.move_fluid(row, offset) + + @Slot(int, name="requestSweepFluidRemove") + def request_sweep_fluid_remove(self, row: int) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.dataset_draft.remove_fluid(row) + + @Slot(str, bool, name="requestSweepPropertySelection") + def request_sweep_property_selection(self, value: str, selected: bool) -> None: + if not self._can_edit_sweep_document(): + return + draft = self.configuration_controller.sweep_draft.dataset_draft + if selected: + draft.add_property(value) + else: + draft.remove_property_value(value) + + @Slot(int, int, name="requestSweepPropertyMove") + def request_sweep_property_move(self, row: int, offset: int) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.dataset_draft.move_property(row, offset) + + @Slot(int, name="requestSweepPropertyRemove") + def request_sweep_property_remove(self, row: int) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.dataset_draft.remove_property(row) + + @Slot(str, bool, name="requestSweepOutputSelection") + def request_sweep_output_selection(self, output_format: str, selected: bool) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.dataset_draft.set_output_selected( + output_format, + selected, + ) + + @Slot(QObject, str, name="requestSweepSamplerKindChange") + def request_sweep_sampler_kind_change(self, candidate: QObject, kind: str) -> None: + sampler = self._owned_sweep_sampler(candidate) + if sampler is not None and self._can_edit_sweep_document(): + sampler.set_kind(kind) + + @Slot(QObject, str, str, name="requestSweepSamplerTextChange") + def request_sweep_sampler_text_change( + self, + candidate: QObject, + field: str, + text: str, + ) -> None: + sampler = self._owned_sweep_sampler(candidate) + if sampler is not None and self._can_edit_sweep_document(): + sampler.set_text(field, text) + + @Slot(QObject, str, name="requestSweepSamplerUnitChange") + def request_sweep_sampler_unit_change(self, candidate: QObject, unit: str) -> None: + sampler = self._owned_sweep_sampler(candidate) + if sampler is not None and self._can_edit_sweep_document(): + sampler.requestUnitChange(unit) + + @Slot(str, name="requestSweepComparisonFormat") + def request_sweep_comparison_format(self, output_format: str) -> None: + if self._can_edit_sweep_document(): + self.configuration_controller.sweep_draft.set_comparison_format(output_format) + + @Slot(result=bool, name="requestSweepAddComparison") + def request_sweep_add_comparison(self) -> bool: + return self._can_edit_sweep_document() and ( + self.configuration_controller.sweep_draft.begin_add_comparison() + ) + + @Slot(int, result=bool, name="requestSweepEditComparison") + def request_sweep_edit_comparison(self, row: int) -> bool: + return self._can_edit_sweep_document() and ( + self.configuration_controller.sweep_draft.begin_edit_comparison(row) + ) + + @Slot(result=bool, name="requestSweepCommitComparison") + def request_sweep_commit_comparison(self) -> bool: + return self._can_edit_sweep_document() and ( + self.configuration_controller.sweep_draft.commit_comparison() + ) + + @Slot(result=bool, name="requestSweepCancelComparison") + def request_sweep_cancel_comparison(self) -> bool: + return self._can_edit_sweep_document() and ( + self.configuration_controller.sweep_draft.cancel_comparison() + ) + + @Slot(int, result=bool, name="requestSweepRemoveComparison") + def request_sweep_remove_comparison(self, row: int) -> bool: + return self._can_edit_sweep_document() and ( + self.configuration_controller.sweep_draft.remove_comparison(row) + ) + + @Slot(int, int, result=bool, name="requestSweepMoveComparison") + def request_sweep_move_comparison(self, source: int, destination: int) -> bool: + return self._can_edit_sweep_document() and ( + self.configuration_controller.sweep_draft.move_comparison(source, destination) + ) + + @Slot(QObject, str, str, name="requestSweepComparisonFieldChange") + def request_sweep_comparison_field_change( + self, + candidate: QObject, + field: str, + value: str, + ) -> None: + draft = self._owned_sweep_comparison(candidate) + if draft is None or not self._can_edit_sweep_document(): + return + setters = { + "name": draft.set_name, + "kind": draft.set_kind, + "fluid": draft.set_fluid, + "property": draft.set_property_name, + "x": draft.set_x_field, + "group_by": draft.set_group_by, + "delta_metric": draft.set_delta_metric, + "value_scale": draft.set_value_scale, + "format": draft.set_output_format, + } + setter = setters.get(field) + if setter is not None: + setter(value) + + @Slot(QObject, bool, name="requestSweepComparisonExplicitModels") + def request_sweep_comparison_explicit_models( + self, + candidate: QObject, + enabled: bool, + ) -> None: + draft = self._owned_sweep_comparison(candidate) + if draft is not None and self._can_edit_sweep_document(): + draft.set_explicit_models(enabled) + + @Slot(QObject, str, bool, name="requestSweepComparisonModelSelection") + def request_sweep_comparison_model_selection( + self, + candidate: QObject, + model: str, + selected: bool, + ) -> None: + draft = self._owned_sweep_comparison(candidate) + if draft is not None and self._can_edit_sweep_document(): + draft.set_model_selected(model, selected) + + @Slot(QObject, name="requestSweepComparisonFilterAdd") + def request_sweep_comparison_filter_add(self, candidate: QObject) -> None: + mapping = self._owned_sweep_comparison_mapping(candidate) + if mapping is not None and self._can_edit_sweep_document(): + mapping.add_row() + + @Slot(QObject, int, str, name="requestSweepComparisonFilterFieldChange") + def request_sweep_comparison_filter_field_change( + self, + candidate: QObject, + row: int, + field: str, + ) -> None: + mapping = self._owned_sweep_comparison_mapping(candidate) + if mapping is not None and self._can_edit_sweep_document(): + mapping.set_field(row, field) + + @Slot(QObject, int, str, name="requestSweepComparisonFilterValueChange") + def request_sweep_comparison_filter_value_change( + self, + candidate: QObject, + row: int, + value: str, + ) -> None: + mapping = self._owned_sweep_comparison_mapping(candidate) + if mapping is not None and self._can_edit_sweep_document(): + mapping.set_raw_value(row, value) + + @Slot(QObject, int, name="requestSweepComparisonFilterRemove") + def request_sweep_comparison_filter_remove(self, candidate: QObject, row: int) -> None: + mapping = self._owned_sweep_comparison_mapping(candidate) + if mapping is not None and self._can_edit_sweep_document(): + mapping.remove_row(row) + + @Slot(str, str, bool, name="requestPreparationRoleSelection") + def request_preparation_role_selection( + self, + role: str, + value: str, + selected: bool, + ) -> None: + if self._can_edit_preparation_document(): + self.configuration_controller.preparation_draft.set_role_selected( + role, + value, + selected, + ) + + @Slot(str, bool, name="requestPreparationCategoricalSelection") + def request_preparation_categorical_selection(self, field: str, selected: bool) -> None: + if self._can_edit_preparation_document(): + self.configuration_controller.preparation_draft.set_categorical_selected( + field, + selected, + ) + + @Slot(str, str, bool, name="requestPreparationCategoryMode") + def request_preparation_category_mode( + self, + field: str, + mode: str, + discard_confirmed: bool, + ) -> None: + if self._can_edit_preparation_document(): + self.configuration_controller.preparation_draft.set_category_mode( + field, + mode, + discard_confirmed, + ) + + @Slot(str, str, name="requestPreparationExplicitCategories") + def request_preparation_explicit_categories(self, field: str, values: str) -> None: + if self._can_edit_preparation_document(): + self.configuration_controller.preparation_draft.set_explicit_categories( + field, + values, + ) + + @Slot(str, bool, name="requestPreparationBooleanField") + def request_preparation_boolean_field(self, field: str, value: bool) -> None: + if not self._can_edit_preparation_document(): + return + draft = self.configuration_controller.preparation_draft + setter = { + "allow_partial_sweep": draft.set_allow_partial_sweep, + "array_outputs": draft.set_array_outputs_enabled, + "include_auxiliary": draft.set_include_auxiliary, + "matrix_diagnostics": draft.set_matrix_enabled, + "baseline_diagnostics": draft.set_baseline_enabled, + }.get(field) + if setter is not None: + setter(value) + + @Slot(str, str, name="requestPreparationTextField") + def request_preparation_text_field(self, field: str, value: str) -> None: + if not self._can_edit_preparation_document(): + return + draft = self.configuration_controller.preparation_draft + setter = { + "array_dtype": draft.set_array_dtype, + "correlation_threshold": draft.set_correlation_threshold, + "near_constant_relative_spread": draft.set_near_constant_spread, + "baseline_random_seed": draft.set_baseline_seed, + "ridge_alpha": draft.set_ridge_alpha, + "histogram_max_iterations": draft.set_histogram_iterations, + }.get(field) + if setter is not None: + setter(value) + + @Slot(str, bool, name="requestPreparationArrayFormatSelection") + def request_preparation_array_format_selection( + self, + value: str, + selected: bool, + ) -> None: + if self._can_edit_preparation_document(): + self.configuration_controller.preparation_draft.set_array_format_selected( + value, + selected, + ) + + @Slot(str, bool, name="requestPreparationBaselineModelSelection") + def request_preparation_baseline_model_selection( + self, + value: str, + selected: bool, + ) -> None: + if self._can_edit_preparation_document(): + self.configuration_controller.preparation_draft.set_baseline_model_selected( + value, + selected, + ) + + @Slot(result=bool, name="requestPreparationAddScenario") + def request_preparation_add_scenario(self) -> bool: + return self._can_edit_preparation_document() and ( + self.configuration_controller.preparation_draft.begin_add_scenario() + ) + + @Slot(int, result=bool, name="requestPreparationEditScenario") + def request_preparation_edit_scenario(self, row: int) -> bool: + return self._can_edit_preparation_document() and ( + self.configuration_controller.preparation_draft.begin_edit_scenario(row) + ) + + @Slot(result=bool, name="requestPreparationCommitScenario") + def request_preparation_commit_scenario(self) -> bool: + return self._can_edit_preparation_document() and ( + self.configuration_controller.preparation_draft.commit_scenario() + ) + + @Slot(result=bool, name="requestPreparationCancelScenario") + def request_preparation_cancel_scenario(self) -> bool: + return self._can_edit_preparation_document() and ( + self.configuration_controller.preparation_draft.cancel_scenario() + ) + + @Slot(int, result=bool, name="requestPreparationRemoveScenario") + def request_preparation_remove_scenario(self, row: int) -> bool: + return self._can_edit_preparation_document() and ( + self.configuration_controller.preparation_draft.remove_scenario(row) + ) + + @Slot(int, int, result=bool, name="requestPreparationMoveScenario") + def request_preparation_move_scenario(self, source: int, destination: int) -> bool: + return self._can_edit_preparation_document() and ( + self.configuration_controller.preparation_draft.move_scenario(source, destination) + ) + + @Slot(QObject, str, str, name="requestPreparationScenarioFieldChange") + def request_preparation_scenario_field_change( + self, + candidate: QObject, + field: str, + value: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is None or not self._can_edit_preparation_document(): + return + setter = { + "name": draft.set_name, + "seed": draft.set_seed_text, + "field": draft.set_field, + "remainder": draft.set_remainder, + }.get(field) + if setter is not None: + setter(value) + + @Slot(QObject, str, bool, name="requestPreparationScenarioKindChange") + def request_preparation_scenario_kind_change( + self, + candidate: QObject, + kind: str, + confirmed: bool, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.apply_kind_change(kind, confirmed) + + @Slot(QObject, str, str, name="requestPreparationScenarioPartition") + def request_preparation_scenario_partition( + self, + candidate: QObject, + partition: str, + ratio: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.set_partition(partition, ratio) + + @Slot(QObject, str, name="requestPreparationScenarioRemovePartition") + def request_preparation_scenario_remove_partition( + self, + candidate: QObject, + partition: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.remove_partition(partition) + + @Slot(QObject, str, str, name="requestPreparationScenarioCategoricalHoldout") + def request_preparation_scenario_categorical_holdout( + self, + candidate: QObject, + partition: str, + values: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.set_categorical_holdout(partition, values) + + @Slot(QObject, str, str, str, name="requestPreparationScenarioRangeHoldout") + def request_preparation_scenario_range_holdout( + self, + candidate: QObject, + partition: str, + minimum: str, + maximum: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.set_range_holdout(partition, minimum, maximum) + + @Slot(QObject, str, str, str, str, name="requestPreparationScenarioCoordinateHoldout") + def request_preparation_scenario_coordinate_holdout( + self, + candidate: QObject, + partition: str, + field: str, + minimum: str, + maximum: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.set_coordinate_holdout(partition, field, minimum, maximum) + + @Slot(QObject, str, name="requestPreparationScenarioRemoveHoldout") + def request_preparation_scenario_remove_holdout( + self, + candidate: QObject, + partition: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.remove_holdout(partition) + + @Slot(QObject, str, name="requestPreparationScenarioStrata") + def request_preparation_scenario_strata( + self, + candidate: QObject, + fields: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.set_strata_categorical(fields) + + @Slot(QObject, str, str, name="requestPreparationScenarioNumericBins") + def request_preparation_scenario_numeric_bins( + self, + candidate: QObject, + field: str, + boundaries: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.set_numeric_bins(field, boundaries) + + @Slot(QObject, str, name="requestPreparationScenarioRemoveNumericBins") + def request_preparation_scenario_remove_numeric_bins( + self, + candidate: QObject, + field: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.remove_numeric_bins(field) + + @Slot(QObject, str, str, name="requestPreparationScenarioTransformationAdd") + def request_preparation_scenario_transformation_add( + self, + candidate: QObject, + field: str, + methods: str, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.add_transformation(field, methods) + + @Slot(QObject, int, name="requestPreparationScenarioTransformationRemove") + def request_preparation_scenario_transformation_remove( + self, + candidate: QObject, + row: int, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.remove_transformation(row) + + @Slot(QObject, int, int, name="requestPreparationScenarioTransformationMove") + def request_preparation_scenario_transformation_move( + self, + candidate: QObject, + source: int, + destination: int, + ) -> None: + draft = self._owned_preparation_scenario(candidate) + if draft is not None and self._can_edit_preparation_document(): + draft.move_transformation(source, destination) + @Slot(bool, name="requestVisualizationEnabled") def request_visualization_enabled(self, enabled: bool) -> None: - if self._guard_active_plot_edit("visualization enable or disable"): + if self._can_edit_dataset_document() and self._guard_active_plot_edit( + "visualization enable or disable" + ): self.visualization_draft.set_enabled(enabled) @Slot(str, name="requestVisualizationFormat") def request_visualization_format(self, output_format: str) -> None: - if self._guard_active_plot_edit("shared visualization format change"): + if self._can_edit_dataset_document() and self._guard_active_plot_edit( + "shared visualization format change" + ): self.visualization_draft.set_format(output_format) @Slot(str, bool, name="requestVisualizationFluidSelection") def request_visualization_fluid_selection(self, value: str, selected: bool) -> None: - if self._guard_active_plot_edit("shared visualization fluid change"): + if self._can_edit_dataset_document() and self._guard_active_plot_edit( + "shared visualization fluid change" + ): self.visualization_draft.set_fluid_selected(value, selected) @Slot(result=bool, name="requestVisualizationAddPlot") def request_visualization_add_plot(self) -> bool: - if not self._guard_active_plot_edit("starting another plot edit"): + if not self._can_edit_dataset_document() or not self._guard_active_plot_edit( + "starting another plot edit" + ): return False return self.visualization_draft.begin_add_plot() is not None @Slot(int, result=bool, name="requestVisualizationEditPlot") def request_visualization_edit_plot(self, row: int) -> bool: - if not self._guard_active_plot_edit("starting another plot edit"): + if not self._can_edit_dataset_document() or not self._guard_active_plot_edit( + "starting another plot edit" + ): return False return self.visualization_draft.begin_edit_plot(row) is not None @@ -786,21 +1467,25 @@ def request_configured_plot_session_edit(self, row: int) -> bool: @Slot(result=bool, name="requestVisualizationCommitPlot") def request_visualization_commit_plot(self) -> bool: - return self.visualization_draft.commit_plot() + return self._can_edit_dataset_document() and self.visualization_draft.commit_plot() @Slot(result=bool, name="requestVisualizationCancelPlot") def request_visualization_cancel_plot(self) -> bool: - return self.visualization_draft.cancel_plot() + return self._can_edit_dataset_document() and self.visualization_draft.cancel_plot() @Slot(int, result=bool, name="requestVisualizationRemovePlot") def request_visualization_remove_plot(self, row: int) -> bool: - if not self._guard_active_plot_edit("plot removal"): + if not self._can_edit_dataset_document() or not self._guard_active_plot_edit( + "plot removal" + ): return False return self.visualization_draft.remove_plot(row) @Slot(int, int, result=bool, name="requestVisualizationMovePlot") def request_visualization_move_plot(self, row: int, offset: int) -> bool: - if not self._guard_active_plot_edit("plot movement"): + if not self._can_edit_dataset_document() or not self._guard_active_plot_edit( + "plot movement" + ): return False return self.visualization_draft.move_plot(row, offset) @@ -917,19 +1602,64 @@ def _owned_dataset_sampler(self, candidate: QObject) -> SamplerDraft | None: None, ) - def _owned_active_plot(self, candidate: QObject) -> PlotDraft | None: - active_drafts = ( - self.visualization_draft.get_active_plot_draft(), - self.session_plot_controller.get_active_plot_draft(), + def _can_edit_dataset_document(self) -> bool: + return self._can_edit_document("dataset", "Dataset") + + def _can_edit_sweep_document(self) -> bool: + return self._can_edit_document("model_sweep", "Model Sweep") + + def _can_edit_preparation_document(self) -> bool: + return self._can_edit_document("preparation", "ML Preparation") + + def _can_edit_document(self, document_kind: str, label: str) -> bool: + if self.configuration_controller.get_document_kind() != document_kind: + return False + if self.configuration_controller.get_can_edit(): + return True + self.workspace_controller.report_error( + f"Wait for the active worker request before editing the {label} configuration." ) - return next( - ( - active - for active in active_drafts - if isinstance(active, PlotDraft) and active is candidate - ), - None, + return False + + def _owned_sweep_sampler(self, candidate: QObject) -> SamplerDraft | None: + samplers = self.configuration_controller.sweep_draft.dataset_draft.samplers.drafts + return next((sampler for sampler in samplers if sampler is candidate), None) + + def _owned_sweep_comparison( + self, + candidate: QObject, + ) -> ComparisonPlotDraft | None: + active = self.configuration_controller.sweep_draft.get_active_comparison_draft() + return active if isinstance(active, ComparisonPlotDraft) and active is candidate else None + + def _owned_sweep_comparison_mapping( + self, + candidate: QObject, + ) -> MappingDraftModel | None: + active = self.configuration_controller.sweep_draft.get_active_comparison_draft() + if not isinstance(active, ComparisonPlotDraft): + return None + if candidate is not active.filters: + return None + return candidate if isinstance(candidate, MappingDraftModel) else None + + def _owned_preparation_scenario(self, candidate: QObject) -> ScenarioDraft | None: + active = self.configuration_controller.preparation_draft.get_active_scenario_draft() + return active if isinstance(active, ScenarioDraft) and active is candidate else None + + def _owned_active_plot(self, candidate: QObject) -> PlotDraft | None: + configured = self.visualization_draft.get_active_plot_draft() + if isinstance(configured, PlotDraft) and configured is candidate: + return configured if self._can_edit_dataset_document() else None + session = self.session_plot_controller.get_active_plot_draft() + if not isinstance(session, PlotDraft) or session is not candidate: + return None + if not self.session_plot_controller.get_is_rendering(): + return session + self.workspace_controller.report_error( + "Wait for the active plot worker before editing the session plot." ) + return None def _owned_visualization_mapping( self, @@ -940,6 +1670,8 @@ def _owned_visualization_mapping( self.visualization_draft.display_units, ) if candidate in shared: + if not self._can_edit_dataset_document(): + return None if not self._guard_active_plot_edit("shared visualization mapping change"): return None return candidate if isinstance(candidate, MappingDraftModel) else None @@ -950,8 +1682,9 @@ def _owned_visualization_mapping( if not isinstance(active, PlotDraft): continue mappings = (active.filters, active.series, active.display_units) - if isinstance(candidate, MappingDraftModel) and candidate in mappings: - return candidate + if not isinstance(candidate, MappingDraftModel) or candidate not in mappings: + continue + return candidate if self._owned_active_plot(active) is active else None return None @Slot(str, str, result=bool, name="prepareCreateWorkspace") @@ -1030,7 +1763,7 @@ def request_cancel_workspace_operation(self) -> None: def shutdown(self) -> bool: if self._shutdown: return True - if not self._guard_active_plot_edit("closing Carnopy"): + if not self._guard_configuration_lifecycle("closing Carnopy"): return False if not self._guard_session_plot_edit("closing Carnopy"): return False @@ -1046,8 +1779,28 @@ def shutdown(self) -> bool: @Slot(result=bool, name="requestShutdown") def request_shutdown(self) -> bool: + if self._shutdown: + return True active_session = self.request_coordinator.active_session if active_session is not None: + if ( + self._pending_busy_shutdown + and active_session is self._pending_busy_shutdown_session + ): + self.workspace_controller.report_error( + "Carnopy will close after the active worker request finishes safely." + ) + return False + self._clear_pending_shutdown_decisions() + if bool(getattr(active_session, "termination_protected", False)): + self._pending_busy_shutdown = "protected_finalization" + self._pending_busy_shutdown_session = active_session + self.workspace_controller.report_error( + "Finalizing safely. Carnopy will close after the worker finishes and " + "the remaining close checks pass." + ) + return False + self._pending_busy_confirmation_session = active_session if ( active_session.owner == "execution" and active_session.request_type == "generate_dataset" @@ -1057,6 +1810,21 @@ def request_shutdown(self) -> bool: "Dataset generation is active. Cancel it cooperatively and close " "Carnopy after the worker and activity record finish safely?", ) + elif active_session.owner == "sweep" and active_session.request_type == "execute_sweep": + self.busyShutdownConfirmationRequested.emit( + "cancel_sweep", + "Model Sweep execution is active. Cancel it cooperatively and close " + "Carnopy after the worker and activity record finish safely?", + ) + elif ( + active_session.owner == "preparation" + and active_session.request_type == "execute_preparation" + ): + self.busyShutdownConfirmationRequested.emit( + "cancel_preparation", + "ML Preparation execution is active. Cancel it cooperatively and close " + "Carnopy after the worker and activity record finish safely?", + ) elif ( active_session.owner == "plot" and active_session.request_type == "render_plot" @@ -1068,79 +1836,143 @@ def request_shutdown(self) -> bool: "after its owned staging cleanup succeeds?", ) else: + self._pending_busy_confirmation_session = None self.workspace_controller.report_error( "Wait for the active worker request to finish before closing Carnopy." ) return False if self.request_coordinator.is_busy: + self._pending_busy_confirmation_session = None self.workspace_controller.report_error( "Wait for the active worker request to finish before closing Carnopy." ) return False if self.get_has_any_transient_edit(): + self._pending_busy_confirmation_session = None + self._approved_discard_shutdown_context = None + self._pending_discard_shutdown_context = None + self._pending_transient_shutdown_context = self._transient_shutdown_context() edit_names = [] if self.get_has_active_plot_edit(): edit_names.append("configured plot") if self.get_has_session_plot_edit(): edit_names.append("session plot") + if self.get_has_active_sweep_edit(): + edit_names.append("Sweep comparison") + if self.get_has_active_preparation_edit(): + edit_names.append("Preparation scenario") description = " and ".join(edit_names) self.transientEditShutdownConfirmationRequested.emit( f"A {description} edit is still open. Cancel the edit and close Carnopy?" ) return False - if ( - self.dataset_config_controller.needs_discard_confirmation() - and not self._shutdown_discard_confirmed - ): - self.shutdownConfirmationRequested.emit() - return False - self._shutdown_discard_confirmed = False + self._pending_transient_shutdown_context = None + if self.configuration_controller.needs_discard_confirmation(): + context = self._discard_shutdown_context() + if not self._same_discard_shutdown_context( + self._approved_discard_shutdown_context, + context, + ): + self._approved_discard_shutdown_context = None + self._pending_discard_shutdown_context = context + self.shutdownConfirmationRequested.emit() + return False + self._pending_discard_shutdown_context = None + self._approved_discard_shutdown_context = None return self.shutdown() @Slot(bool, result=bool, name="confirmBusyShutdown") def confirm_busy_shutdown(self, confirmed: bool) -> bool: if not confirmed: - self._pending_busy_shutdown = "" + self._clear_pending_busy_shutdown() return False session = self.request_coordinator.active_session - if session is None: + if session is None or session is not self._pending_busy_confirmation_session: + self._clear_pending_busy_shutdown() + self.workspace_controller.report_error( + "The active worker request changed. Close Carnopy again to review it." + ) return False + self._pending_busy_confirmation_session = None + self._pending_busy_shutdown_session = session if session.owner == "execution" and session.request_type == "generate_dataset": self._pending_busy_shutdown = "generation_waiting" self._continue_pending_busy_shutdown() return True + if session.owner == "sweep" and session.request_type == "execute_sweep": + self._pending_busy_shutdown = "sweep_waiting" + self._continue_pending_busy_shutdown() + return True + if session.owner == "preparation" and session.request_type == "execute_preparation": + self._pending_busy_shutdown = "preparation_waiting" + self._continue_pending_busy_shutdown() + return True if session.owner == "plot" and session.request_type == "render_plot": if not self.session_plot_controller.force_stop(): + self._clear_pending_busy_shutdown() self.workspace_controller.report_error( "The plot worker cannot be force-stopped safely yet." ) return False self._pending_busy_shutdown = "plot" return True + self._clear_pending_busy_shutdown() return False @Slot(bool, result=bool, name="confirmTransientEditShutdown") def confirm_transient_edit_shutdown(self, discard_confirmed: bool) -> bool: if not discard_confirmed: + self._pending_transient_shutdown_context = None return False if self.request_coordinator.is_busy: + self._pending_transient_shutdown_context = None self.workspace_controller.report_error( "Wait for the active worker request to finish before closing Carnopy." ) return False + context = self._pending_transient_shutdown_context + if not self._same_transient_shutdown_context( + context, + self._transient_shutdown_context(), + ): + self._pending_transient_shutdown_context = None + self.workspace_controller.report_error( + "The unfinished edits changed. Close Carnopy again to review them." + ) + return False + self._pending_transient_shutdown_context = None if self.get_has_active_plot_edit() and not self.visualization_draft.cancel_plot(): return False if self.get_has_session_plot_edit() and not self.session_plot_controller.cancel_edit(): return False - self.closeWindowRequested.emit() + if ( + self.get_has_active_sweep_edit() + and not self.configuration_controller.sweep_draft.cancel_comparison() + ): + return False + if ( + self.get_has_active_preparation_edit() + and not self.configuration_controller.preparation_draft.cancel_scenario() + ): + return False + self._continue_guarded_shutdown() return True @Slot(bool, result=bool, name="confirmShutdown") def confirm_shutdown(self, discard_confirmed: bool) -> bool: if not discard_confirmed: - self._shutdown_discard_confirmed = False + self._pending_discard_shutdown_context = None + self._approved_discard_shutdown_context = None + return False + context = self._pending_discard_shutdown_context + if not self._same_discard_shutdown_context(context, self._discard_shutdown_context()): + self._pending_discard_shutdown_context = None + self._approved_discard_shutdown_context = None + self.workspace_controller.report_error( + "The open configuration changed. Close Carnopy again to review it." + ) return False - if not self._guard_active_plot_edit("closing Carnopy"): + if not self._guard_configuration_lifecycle("closing Carnopy"): return False if not self._guard_session_plot_edit("closing Carnopy"): return False @@ -1149,13 +1981,13 @@ def confirm_shutdown(self, discard_confirmed: bool) -> bool: "Wait for the active worker request to finish before closing Carnopy." ) return False - self._shutdown_discard_confirmed = True - self.closeWindowRequested.emit() - return True + self._pending_discard_shutdown_context = None + self._approved_discard_shutdown_context = context + return self._continue_guarded_shutdown() def _workspace_activated(self, value: object) -> None: self._pending_explore_source = None - self.dataset_config_controller.set_workspace(value) + self.configuration_controller.set_workspace(value) self.execution_controller.set_workspace(value if isinstance(value, Workspace) else None) self.session_plot_controller.set_workspace(value if isinstance(value, Workspace) else None) self.inspection_controller.set_workspace(value if isinstance(value, Workspace) else None) @@ -1173,33 +2005,85 @@ def _workspace_activated(self, value: object) -> None: self.workspace_confirmation_changed.emit() def _configuration_state_changed(self) -> None: + self._configuration_state_revision += 1 self.workspace_state_changed.emit() self.workspace_confirmation_changed.emit() + def _preparation_source_binding_changed(self) -> None: + snapshot = self.preparation_workflow_controller.bound_source_snapshot() + profile = None if snapshot is None else snapshot[3] + self.configuration_controller.preparation_draft.apply_source_profile(profile) + def _request_state_changed(self, busy: bool) -> None: self.workspace_state_changed.emit() + if not busy: + self._pending_busy_confirmation_session = None if not busy and self._pending_busy_shutdown: QTimer.singleShot(0, self._complete_busy_shutdown) def _continue_pending_busy_shutdown(self) -> None: - if self._pending_busy_shutdown != "generation_waiting": + mode = self._pending_busy_shutdown + if mode not in { + "generation_waiting", + "sweep_waiting", + "preparation_waiting", + }: return session = self.request_coordinator.active_session + if session is None: + return + if session is not self._pending_busy_shutdown_session: + self._clear_pending_busy_shutdown() + self.workspace_controller.report_error( + "Another worker request started. Close Carnopy again to review it." + ) + return + if mode == "generation_waiting": + if ( + session.owner != "execution" + or session.request_type != "generate_dataset" + or not self.execution_controller.get_can_cancel() + ): + return + self._pending_busy_shutdown = "generation" + if not self.execution_controller.cancel(): + self._pending_busy_shutdown = mode + return + if mode == "sweep_waiting": + if ( + session.owner != "sweep" + or session.request_type != "execute_sweep" + or not self.sweep_workflow_controller.get_cancellation_available() + ): + return + self._pending_busy_shutdown = "sweep" + if not self.sweep_workflow_controller.cancel(): + self._pending_busy_shutdown = mode + return if ( - session is None - or session.owner != "execution" - or session.request_type != "generate_dataset" - or not self.execution_controller.get_can_cancel() + session.owner != "preparation" + or session.request_type != "execute_preparation" + or not self.preparation_workflow_controller.get_cancellation_available() ): return - self._pending_busy_shutdown = "generation" - if not self.execution_controller.cancel(): - self._pending_busy_shutdown = "generation_waiting" + self._pending_busy_shutdown = "preparation" + if not self.preparation_workflow_controller.cancel(): + self._pending_busy_shutdown = mode def _complete_busy_shutdown(self) -> None: - mode, self._pending_busy_shutdown = self._pending_busy_shutdown, "" - if not mode or self.request_coordinator.is_busy: + mode = self._pending_busy_shutdown + if not mode: + return + active_session = self.request_coordinator.active_session + if active_session is self._pending_busy_shutdown_session: return + if self.request_coordinator.is_busy: + self._clear_pending_busy_shutdown() + self.workspace_controller.report_error( + "Another worker request started. Close Carnopy again to review it." + ) + return + self._clear_pending_busy_shutdown() if mode == "plot": cleanup_issue = self.session_plot_controller.get_cleanup_issue() if cleanup_issue: @@ -1215,22 +2099,118 @@ def _complete_busy_shutdown(self) -> None: "The stopped session plot edit could not be cancelled safely." ) return - if self.request_shutdown(): - self.closeWindowRequested.emit() + self._continue_guarded_shutdown() + + def _continue_guarded_shutdown(self) -> bool: + if not self.request_shutdown(): + return False + self.closeWindowRequested.emit() + return True + + def _clear_pending_busy_shutdown(self) -> None: + self._pending_busy_shutdown = "" + self._pending_busy_confirmation_session = None + self._pending_busy_shutdown_session = None + + def _clear_pending_shutdown_decisions(self) -> None: + self._pending_transient_shutdown_context = None + self._pending_discard_shutdown_context = None + self._approved_discard_shutdown_context = None + self._pending_busy_shutdown = "" + self._pending_busy_shutdown_session = None + self._pending_busy_confirmation_session = None + + def _transient_shutdown_context( + self, + ) -> tuple[tuple[bool, object | None], ...]: + return ( + ( + self.get_has_active_plot_edit(), + self.visualization_draft.get_active_plot_draft(), + ), + ( + self.get_has_session_plot_edit(), + self.session_plot_controller.get_active_plot_draft(), + ), + ( + self.get_has_active_sweep_edit(), + self.configuration_controller.sweep_draft.get_active_comparison_draft(), + ), + ( + self.get_has_active_preparation_edit(), + self.configuration_controller.preparation_draft.get_active_scenario_draft(), + ), + ) + + @staticmethod + def _same_transient_shutdown_context( + expected: tuple[tuple[bool, object | None], ...] | None, + current: tuple[tuple[bool, object | None], ...], + ) -> bool: + if expected is None or len(expected) != len(current): + return False + return all( + expected_active == current_active and expected_draft is current_draft + for (expected_active, expected_draft), (current_active, current_draft) in zip( + expected, + current, + strict=True, + ) + ) + + def _discard_shutdown_context(self) -> tuple[object | None, int]: + return ( + self.configuration_controller.document, + self._configuration_state_revision, + ) + + @staticmethod + def _same_discard_shutdown_context( + expected: tuple[object | None, int] | None, + current: tuple[object | None, int], + ) -> bool: + return expected is not None and expected[0] is current[0] and expected[1] == current[1] def _active_plot_state_changed(self) -> None: self.workspace_state_changed.emit() self.workspace_confirmation_changed.emit() def _guard_workspace_change(self, *, before_commit: bool) -> bool: - if self._guard_active_plot_edit() and self._guard_session_plot_edit( + if self._guard_configuration_lifecycle( "replacing the workspace" - ): + ) and self._guard_session_plot_edit("replacing the workspace"): return True if before_commit: self.workspace_controller.cancel_pending() return False + def _guard_idle_configuration_action(self, operation: str) -> bool: + if not self._guard_configuration_lifecycle(operation): + return False + if not self.request_coordinator.is_busy: + return True + self.workspace_controller.report_error( + f"Wait for the active worker request before {operation}." + ) + return False + + def _guard_configuration_lifecycle(self, operation: str = "this operation") -> bool: + if not self._guard_active_plot_edit(operation) or not self._guard_workflow_nested_edit( + operation + ): + return False + if operation != "New ML Preparation" or not self.configuration_controller.get_can_create(): + return True + if self.preparation_workflow_controller.get_has_bound_source(): + return True + message = ( + "Inspect an eligible Dataset or Model Sweep and choose " + "Use for ML Preparation before creating a Preparation configuration." + ) + self.activityActionFailed.emit("New ML Preparation", message) + self.navigationRequested.emit("inspect", "preparation-source") + return False + def _guard_active_plot_edit(self, operation: str = "this operation") -> bool: if self.visualization_draft.get_active_plot_draft() is None: return True @@ -1244,6 +2224,31 @@ def _guard_active_plot_edit(self, operation: str = "this operation") -> bool: ) return False + def _guard_workflow_nested_edit(self, operation: str = "this operation") -> bool: + sweep = self.configuration_controller.sweep_draft + if sweep.get_has_active_comparison_edit(): + message = f"Commit or cancel the active Sweep comparison edit before {operation}." + sweep.message.emit(message) + self.workspace_controller.report_error(message) + self.attentionRequested.emit( + "sweep", + sweep.get_first_invalid_field(), + sweep.get_first_invalid_row(), + ) + return False + preparation = self.configuration_controller.preparation_draft + if preparation.get_has_active_scenario_edit(): + message = f"Commit or cancel the active Preparation scenario edit before {operation}." + preparation.message.emit(message) + self.workspace_controller.report_error(message) + self.attentionRequested.emit( + "preparation", + preparation.get_first_invalid_field(), + preparation.get_first_invalid_row(), + ) + return False + return True + def _guard_session_plot_edit(self, operation: str = "this operation") -> bool: if self.session_plot_controller.can_replace_inspection(operation): return True @@ -1253,6 +2258,16 @@ def _guard_session_plot_edit(self, operation: str = "this operation") -> bool: def _plot_commit_rejected(self, field: str, row: int, _message: str) -> None: self.attentionRequested.emit("visualization", field, row) + def _workflow_controller( + self, + workflow: str, + ) -> SweepWorkflowController | PreparationWorkflowController | None: + if workflow in {"sweep", "model_sweep"}: + return self.sweep_workflow_controller + if workflow == "preparation": + return self.preparation_workflow_controller + return None + def _inspect_run(self, source: str, *, navigate: bool) -> bool: if not source: self.activityActionFailed.emit( @@ -1364,6 +2379,10 @@ def _local_path(value: str) -> str: return QUrl(candidate).toLocalFile() +def _workflow_label(workflow: str) -> str: + return "Model Sweep" if workflow in {"sweep", "model_sweep"} else "ML Preparation" + + def _valid_workspace_child_name(value: str) -> bool: if not value or value in {".", ".."} or "/" in value or "\\" in value: return False diff --git a/src/carnopy/app/execution_controller.py b/src/carnopy/app/execution_controller.py index 96f5c8a..052f902 100644 --- a/src/carnopy/app/execution_controller.py +++ b/src/carnopy/app/execution_controller.py @@ -5,7 +5,7 @@ from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot -from carnopy.app.config_controller import DatasetConfigController +from carnopy.app.config_controller import ConfigurationController from carnopy.app.config_document import ( ConfigDocumentError, SavedConfigSnapshot, @@ -34,7 +34,7 @@ class DatasetExecutionController(QObject): def __init__( self, coordinator: DesktopRequestCoordinator, - config_controller: DatasetConfigController, + config_controller: ConfigurationController, parent: QObject | None = None, ) -> None: super().__init__(parent) @@ -342,7 +342,7 @@ def set_workspace(self, workspace: Workspace | None) -> None: @Slot(name="refreshConfiguration") def refresh_configuration(self) -> None: try: - snapshot = self.config_controller.execution_snapshot() + snapshot = self.config_controller.execution_snapshot(expected_document_type="dataset") except ConfigDocumentError as exc: self.snapshot = None self._snapshot_issue = str(exc) diff --git a/src/carnopy/app/field_ids.py b/src/carnopy/app/field_ids.py index e9bae36..4e2d08b 100644 --- a/src/carnopy/app/field_ids.py +++ b/src/carnopy/app/field_ids.py @@ -20,6 +20,24 @@ PLOT_SERIES = "plot.series" PLOT_DISPLAY_UNITS = "plot.display_units" +PREPARATION_SOURCE_POLICY = "preparation.source_policy.allow_partial_sweep" +PREPARATION_FEATURES = "preparation.features" +PREPARATION_NUMERIC_FEATURES = "preparation.features.numeric" +PREPARATION_DERIVED_FEATURES = "preparation.features.derived" +PREPARATION_CATEGORICAL_FEATURES = "preparation.categorical_features" +PREPARATION_TARGETS = "preparation.targets" +PREPARATION_AUXILIARY = "preparation.auxiliary" +PREPARATION_OUTPUTS = "preparation.outputs" +PREPARATION_MATRIX_DIAGNOSTICS = "preparation.quality.matrix_diagnostics" +PREPARATION_BASELINE_DIAGNOSTICS = "preparation.quality.baseline_diagnostics" +PREPARATION_SCENARIO_ACTIVE = "preparation.scenario.active" + + +def preparation_scenario_field(field: str) -> str: + """Return one stable private field identifier for the temporary scenario editor.""" + + return f"{PREPARATION_SCENARIO_ACTIVE}.{field}" + def dataset_grid_field(axis: str, field: str) -> str: """Return one stable private field identifier for a sampler control.""" diff --git a/src/carnopy/app/inspection_controller.py b/src/carnopy/app/inspection_controller.py index b51860f..d5c2403 100644 --- a/src/carnopy/app/inspection_controller.py +++ b/src/carnopy/app/inspection_controller.py @@ -13,6 +13,8 @@ from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot from carnopy.app.inspection_models import InspectionListModel +from carnopy.app.preparation_audit import PreparationAuditProjection +from carnopy.app.preparation_audit_models import PreparationAuditModel from carnopy.app.protocol import RequestType from carnopy.app.request_coordinator import ( DesktopRequestCoordinator, @@ -33,6 +35,17 @@ class SourceCandidate: modified_ns: int +@dataclass(frozen=True) +class _InspectionRequestContext: + kind: str + workspace_root: Path | None + source: Path | None + inspection_revision: str + table_id: str + block_offset: int + page_offset: int + + class InspectionController(QObject): """Own source inspection, typed projections, and revision-bound previews.""" @@ -56,6 +69,7 @@ def __init__( self._preparation_eligible = False self._preparation_ineligible_reason = "" self._preparation_source_descriptor: dict[str, Any] | None = None + self._preparation_profile: dict[str, Any] | None = None self._integrity_status = "" self._integrity_label = "" self._issue = "" @@ -65,10 +79,12 @@ def __init__( self._plot_context: dict[str, Any] | None = None self._session: RequestSession | None = None self._request_kind = "" + self._request_context: _InspectionRequestContext | None = None self._requested_page_offset = 0 self._requested_block_offset = 0 self._requested_revision = "" self._requested_table_id = "" + self._requested_inspection_source: Path | None = None self._source_candidates: tuple[SourceCandidate, ...] = () self._source_issues: dict[Path, str] = {} self._revealed_source_count = SOURCE_PAGE_SIZE @@ -96,6 +112,65 @@ def __init__( self.failure_property_counts_model = InspectionListModel(("property", "count"), self) self.sweep_delta_reason_counts_model = InspectionListModel(("reason", "count"), self) self.preparation_quality_errors_model = InspectionListModel(("message",), self) + self.preparation_audit_model = PreparationAuditModel(self) + preparation_field_roles = ( + "name", + "column", + "unit", + "source", + "referenceDependent", + ) + self.preparation_models_model = InspectionListModel( + ("name", "available", "declared", "missing", "reference"), + self, + ) + self.preparation_numeric_candidates_model = InspectionListModel( + preparation_field_roles, + self, + ) + self.preparation_target_candidates_model = InspectionListModel( + preparation_field_roles, + self, + ) + self.preparation_categorical_candidates_model = InspectionListModel( + preparation_field_roles, + self, + ) + self.preparation_auxiliary_candidates_model = InspectionListModel( + preparation_field_roles, + self, + ) + self.preparation_observed_categories_model = InspectionListModel( + ("field", "values", "count"), + self, + ) + self.preparation_derived_features_model = InspectionListModel( + ( + "name", + "status", + "available", + "readyRowCount", + "sourceRowCount", + "reason", + "reasonCodes", + "missingDependencies", + "dependencies", + "unit", + ), + self, + ) + self.preparation_reference_contexts_model = InspectionListModel( + ( + "artifact", + "runId", + "backend", + "backendModel", + "referenceStatePolicy", + "referenceStateBackendModel", + "referenceStateTargets", + ), + self, + ) self.diagnostics_model = InspectionListModel( ("section", "label", "value", "severity", "issue"), self, @@ -154,6 +229,142 @@ def get_preparation_ineligible_reason(self) -> str: notify=state_changed, ) + def get_preparation_profile_available(self) -> bool: + return self._preparation_profile is not None + + preparationProfileAvailable = Property( + bool, + get_preparation_profile_available, + notify=state_changed, + ) + + def get_preparation_profile_current(self) -> bool: + profile = self._preparation_profile + return bool( + self._state == "ready" + and self._preparation_eligible + and isinstance(profile, dict) + and profile.get("inspection_revision") == self._revision + ) + + preparationProfileCurrent = Property( + bool, + get_preparation_profile_current, + notify=state_changed, + ) + + def get_preparation_profile_source_kind(self) -> str: + value = _profile_value(self._preparation_profile, "source_kind") + return value if isinstance(value, str) else "" + + preparationProfileSourceKind = Property( + str, + get_preparation_profile_source_kind, + notify=state_changed, + ) + + def get_preparation_profile_revision(self) -> str: + value = _profile_value(self._preparation_profile, "inspection_revision") + return value if isinstance(value, str) else "" + + preparationProfileRevision = Property( + str, + get_preparation_profile_revision, + notify=state_changed, + ) + + def get_preparation_completion_status(self) -> str: + value = _profile_nested_value(self._preparation_profile, "completion", "status") + return value if isinstance(value, str) else "" + + preparationCompletionStatus = Property( + str, + get_preparation_completion_status, + notify=state_changed, + ) + + def get_preparation_partial_source(self) -> bool: + value = _profile_nested_value(self._preparation_profile, "completion", "partial") + return value if isinstance(value, bool) else False + + preparationPartialSource = Property( + bool, + get_preparation_partial_source, + notify=state_changed, + ) + + def get_preparation_reference_model(self) -> str: + value = _profile_value(self._preparation_profile, "reference_model") + return value if isinstance(value, str) else "" + + preparationReferenceModel = Property( + str, + get_preparation_reference_model, + notify=state_changed, + ) + + def get_preparation_model_holdout_available(self) -> bool: + value = _profile_nested_value(self._preparation_profile, "model_holdout", "available") + return value if isinstance(value, bool) else False + + preparationModelHoldoutAvailable = Property( + bool, + get_preparation_model_holdout_available, + notify=state_changed, + ) + + def get_preparation_model_holdout_reason(self) -> str: + value = _profile_nested_value(self._preparation_profile, "model_holdout", "reason") + return value if isinstance(value, str) else "" + + preparationModelHoldoutReason = Property( + str, + get_preparation_model_holdout_reason, + notify=state_changed, + ) + + def get_preparation_reference_context_compatible(self) -> bool: + value = _profile_nested_value( + self._preparation_profile, + "reference_context", + "compatible", + ) + return value if isinstance(value, bool) else False + + preparationReferenceContextCompatible = Property( + bool, + get_preparation_reference_context_compatible, + notify=state_changed, + ) + + def get_preparation_reference_context_reason_code(self) -> str: + value = _profile_nested_value( + self._preparation_profile, + "reference_context", + "reason_code", + ) + return value if isinstance(value, str) else "" + + preparationReferenceContextReasonCode = Property( + str, + get_preparation_reference_context_reason_code, + notify=state_changed, + ) + + def get_preparation_reference_context_reason(self) -> str: + value = _profile_nested_value( + self._preparation_profile, + "reference_context", + "reason", + ) + return value if isinstance(value, str) else "" + + preparationReferenceContextReason = Property( + str, + get_preparation_reference_context_reason, + notify=state_changed, + ) + def preparation_source_snapshot(self) -> tuple[Path, str, dict[str, Any]] | None: if ( self._state != "ready" @@ -165,6 +376,11 @@ def preparation_source_snapshot(self) -> tuple[Path, str, dict[str, Any]] | None return None return self._source, self._revision, copy.deepcopy(self._preparation_source_descriptor) + def preparation_profile_snapshot(self) -> dict[str, Any] | None: + if not self.get_preparation_profile_current() or self._preparation_profile is None: + return None + return copy.deepcopy(self._preparation_profile) + def get_integrity_status(self) -> str: return self._integrity_status @@ -328,6 +544,79 @@ def get_preparation_quality_errors_model(self) -> QObject: constant=True, ) + def get_preparation_audit_model(self) -> QObject: + return self.preparation_audit_model + + preparationAudit = Property(QObject, get_preparation_audit_model, constant=True) + + def get_preparation_models_model(self) -> QObject: + return self._model_property(self.preparation_models_model) + + preparationModelsModel = Property(QObject, get_preparation_models_model, constant=True) + + def get_preparation_numeric_candidates_model(self) -> QObject: + return self._model_property(self.preparation_numeric_candidates_model) + + preparationNumericCandidatesModel = Property( + QObject, + get_preparation_numeric_candidates_model, + constant=True, + ) + + def get_preparation_target_candidates_model(self) -> QObject: + return self._model_property(self.preparation_target_candidates_model) + + preparationTargetCandidatesModel = Property( + QObject, + get_preparation_target_candidates_model, + constant=True, + ) + + def get_preparation_categorical_candidates_model(self) -> QObject: + return self._model_property(self.preparation_categorical_candidates_model) + + preparationCategoricalCandidatesModel = Property( + QObject, + get_preparation_categorical_candidates_model, + constant=True, + ) + + def get_preparation_auxiliary_candidates_model(self) -> QObject: + return self._model_property(self.preparation_auxiliary_candidates_model) + + preparationAuxiliaryCandidatesModel = Property( + QObject, + get_preparation_auxiliary_candidates_model, + constant=True, + ) + + def get_preparation_observed_categories_model(self) -> QObject: + return self._model_property(self.preparation_observed_categories_model) + + preparationObservedCategoriesModel = Property( + QObject, + get_preparation_observed_categories_model, + constant=True, + ) + + def get_preparation_derived_features_model(self) -> QObject: + return self._model_property(self.preparation_derived_features_model) + + preparationDerivedFeaturesModel = Property( + QObject, + get_preparation_derived_features_model, + constant=True, + ) + + def get_preparation_reference_contexts_model(self) -> QObject: + return self._model_property(self.preparation_reference_contexts_model) + + preparationReferenceContextsModel = Property( + QObject, + get_preparation_reference_contexts_model, + constant=True, + ) + def get_diagnostics_model(self) -> QObject: return self._model_property(self.diagnostics_model) @@ -403,11 +692,13 @@ def inspect_source(self, source: str) -> bool: return False path = Path(source).expanduser().resolve() self._clear_inspection(source=path, state="loading") + self._requested_inspection_source = path self._issue = "" self.state_changed.emit() try: self._start_request("inspect_source", {"source_path": str(path)}, kind="inspection") except Exception as exc: + self._requested_inspection_source = None self._state = "failed" self._issue = str(exc) self.state_changed.emit() @@ -486,6 +777,7 @@ def _start_request( ) self._session = session self._request_kind = kind + self._request_context = self._capture_request_context(kind) session.completed.connect(self._request_completed) def _request_preview_block(self, block_offset: int, page_offset: int) -> bool: @@ -523,8 +815,12 @@ def _request_completed(self, value: object) -> None: if session is None or outcome.request_id != session.request_id: return request_kind = self._request_kind + context = self._request_context self._session = None self._request_kind = "" + self._request_context = None + if context is None or not self._response_context_is_current(context): + return result = outcome.result_payload if result is not None: if request_kind == "inspection": @@ -541,6 +837,7 @@ def _accept_inspection_payload(self, payload: dict[str, Any]) -> None: preparation_eligible = payload.get("preparation_eligible", False) ineligible_reason = payload.get("preparation_ineligible_reason", "") preparation_descriptor = payload.get("preparation_source_descriptor") + preparation_profile = payload.get("preparation_profile") if ( source_kind not in {"dataset", "model_sweep", "preparation"} or not isinstance(revision, str) @@ -552,7 +849,9 @@ def _accept_inspection_payload(self, payload: dict[str, Any]) -> None: or not isinstance(ineligible_reason, str) or (preparation_eligible and source_kind not in {"dataset", "model_sweep"}) or (preparation_eligible and not isinstance(preparation_descriptor, dict)) + or (preparation_eligible and not isinstance(preparation_profile, dict)) or (not preparation_eligible and preparation_descriptor is not None) + or (not preparation_eligible and preparation_profile is not None) ): self._accept_failure( "inspection", @@ -562,24 +861,60 @@ def _accept_inspection_payload(self, payload: dict[str, Any]) -> None: ) return inspected_source = Path(source_value).expanduser().resolve() - if self._source is None or inspected_source != self._source: + requested_source = self._requested_inspection_source or self._source + if requested_source is None or inspected_source != requested_source: self._accept_failure( "inspection", - {"message": "worker inspection result belongs to another source"}, + {"message": "worker inspection result does not match its requested source"}, ) return + if self._source != requested_source: + return + self._requested_inspection_source = None + normalized_profile: dict[str, Any] | None = None if preparation_eligible: assert isinstance(preparation_descriptor, dict) + assert isinstance(preparation_profile, dict) expected_kind = "model_sweep" if source_kind == "model_sweep" else "dataset_run" if ( preparation_descriptor.get("source_path") != str(inspected_source) or preparation_descriptor.get("source_kind") != expected_kind + or preparation_descriptor.get("inspection_revision") != revision ): self._accept_failure( "inspection", {"message": "worker preparation eligibility descriptor is inconsistent"}, ) return + try: + normalized_profile = _validated_preparation_profile( + preparation_profile, + source=inspected_source, + source_kind=expected_kind, + revision=revision, + ) + except ValueError as exc: + self._accept_failure( + "inspection", + {"message": f"worker preparation profile is inconsistent: {exc}"}, + ) + return + audit_projection: PreparationAuditProjection | None = None + if source_kind == "preparation": + try: + audit_projection = PreparationAuditProjection.from_worker_payload(payload) + except ValueError as exc: + self._accept_failure( + "inspection", + {"message": f"worker preparation audit is inconsistent: {exc}"}, + ) + return + elif payload.get("preparation_audit") is not None: + self._accept_failure( + "inspection", + {"message": "worker inspection attached Preparation audit to another source"}, + ) + return self._payload = copy.deepcopy(payload) self._source_kind = source_kind self._revision = revision @@ -590,6 +925,7 @@ def _accept_inspection_payload(self, payload: dict[str, Any]) -> None: if isinstance(preparation_descriptor, dict) else None ) + self._preparation_profile = normalized_profile self._plot_context = ( copy.deepcopy(payload.get("plot_context")) if isinstance(payload.get("plot_context"), dict) @@ -598,7 +934,9 @@ def _accept_inspection_payload(self, payload: dict[str, Any]) -> None: self._issue = "" self._state = "ready" self._preview_state = "empty" - self._project_payload(payload) + self._project_payload(payload, preparation_audit=audit_projection) + if normalized_profile is not None: + self._project_preparation_profile(normalized_profile) first = self.tables_model.get(0) self._selected_table_id = str(first["id"]) if isinstance(first.get("id"), str) else "" self.mark_inspectable(inspected_source) @@ -614,6 +952,7 @@ def _accept_preview_payload(self, payload: dict[str, Any]) -> None: or self._revision != self._requested_revision or self._selected_table_id != self._requested_table_id or payload.get("table_id") != self._requested_table_id + or payload.get("block_offset") != self._requested_block_offset ): self._mark_stale("Table preview no longer matches the current inspection revision.") return @@ -627,6 +966,36 @@ def _accept_preview_payload(self, payload: dict[str, Any]) -> None: self._issue = "" self.state_changed.emit() + def _capture_request_context(self, kind: str) -> _InspectionRequestContext: + return _InspectionRequestContext( + kind=kind, + workspace_root=None if self.workspace is None else self.workspace.root, + source=(self._requested_inspection_source if kind == "inspection" else self._source), + inspection_revision=self._requested_revision if kind == "preview" else "", + table_id=self._requested_table_id if kind == "preview" else "", + block_offset=self._requested_block_offset if kind == "preview" else 0, + page_offset=self._requested_page_offset if kind == "preview" else 0, + ) + + def _response_context_is_current(self, context: _InspectionRequestContext) -> bool: + workspace_root = None if self.workspace is None else self.workspace.root + if workspace_root != context.workspace_root or self._source != context.source: + return False + if context.kind == "inspection": + return self._state == "loading" and self._requested_inspection_source == context.source + if context.kind != "preview": + return False + return ( + self._state == "ready" + and self._preview_state == "loading" + and self._revision == context.inspection_revision + and self._selected_table_id == context.table_id + and self._requested_revision == context.inspection_revision + and self._requested_table_id == context.table_id + and self._requested_block_offset == context.block_offset + and self._requested_page_offset == context.page_offset + ) + def _accept_failure(self, request_kind: str, payload: dict[str, object]) -> None: message = str(payload.get("message", "inspection failed")) if request_kind == "preview": @@ -640,6 +1009,8 @@ def _accept_failure(self, request_kind: str, payload: dict[str, object]) -> None self._preparation_eligible = False self._preparation_ineligible_reason = "" self._preparation_source_descriptor = None + self._preparation_profile = None + self._requested_inspection_source = None self._plot_context = None self._reset_projection() self.inspection_changed.emit(None) @@ -655,6 +1026,8 @@ def _mark_stale(self, message: str) -> None: self._payload = None self._plot_context = None self.table_model.clear() + self.preparation_quality_errors_model.clear() + self.preparation_audit_model.clear() self.inspection_changed.emit(None) self.state_changed.emit() @@ -672,6 +1045,7 @@ def _clear_inspection( self._preparation_eligible = False self._preparation_ineligible_reason = "" self._preparation_source_descriptor = None + self._preparation_profile = None self._integrity_status = "" self._integrity_label = "" self._issue = "" @@ -681,6 +1055,8 @@ def _clear_inspection( self._plot_context = None self._session = None self._request_kind = "" + self._request_context = None + self._requested_inspection_source = None self.table_model.clear() self._reset_projection() if had_payload: @@ -699,13 +1075,27 @@ def _reset_projection(self) -> None: self.failure_property_counts_model, self.sweep_delta_reason_counts_model, self.preparation_quality_errors_model, + self.preparation_models_model, + self.preparation_numeric_candidates_model, + self.preparation_target_candidates_model, + self.preparation_categorical_candidates_model, + self.preparation_auxiliary_candidates_model, + self.preparation_observed_categories_model, + self.preparation_derived_features_model, + self.preparation_reference_contexts_model, self.diagnostics_model, self.tables_model, self.arrays_model, ): model.clear() + self.preparation_audit_model.clear() - def _project_payload(self, payload: dict[str, Any]) -> None: + def _project_payload( + self, + payload: dict[str, Any], + *, + preparation_audit: PreparationAuditProjection | None, + ) -> None: self._reset_projection() summary = cast(dict[str, Any], payload["summary"]) source_kind = cast(str, payload["source_kind"]) @@ -724,7 +1114,8 @@ def _project_payload(self, payload: dict[str, Any]) -> None: elif source_kind == "model_sweep": self._project_sweep(summary) else: - self._project_preparation(summary) + assert preparation_audit is not None + self._project_preparation(summary, preparation_audit) def _project_dataset(self, summary: dict[str, Any]) -> None: source = _mapping(summary.get("source")) @@ -841,7 +1232,11 @@ def _project_sweep(self, summary: dict[str, Any]) -> None: self._integrity_status = "worker_inspected" self._integrity_label = "Worker-inspected model-sweep bundle" - def _project_preparation(self, summary: dict[str, Any]) -> None: + def _project_preparation( + self, + summary: dict[str, Any], + audit: PreparationAuditProjection, + ) -> None: self.source_summary_model.set_rows( _summary_rows(summary, (("source", "Source"), ("status", "Status"))), available=True, @@ -877,15 +1272,11 @@ def _project_preparation(self, summary: dict[str, Any]) -> None: ), available=bool(row_counts), ) - quality = _mapping(summary.get("quality")) - errors = quality.get("errors") - error_rows = ( - ({"message": str(message)} for message in errors) if isinstance(errors, list) else () - ) self.preparation_quality_errors_model.set_rows( - error_rows, - available=isinstance(errors, list), + ({"message": message} for message in audit.quality_errors), + available=True, ) + self.preparation_audit_model.replace(audit) self.diagnostics_model.set_rows( _preparation_diagnostics(summary), available=True, @@ -893,6 +1284,51 @@ def _project_preparation(self, summary: dict[str, Any]) -> None: self._integrity_status = "worker_inspected" self._integrity_label = "Worker-inspected preparation bundle" + def _project_preparation_profile(self, profile: dict[str, Any]) -> None: + available_models = cast(list[str], profile["available_models"]) + declared_models = cast(list[str], profile["declared_models"]) + completion = cast(dict[str, Any], profile["completion"]) + missing_models = cast(list[str], completion["missing_child_models"]) + reference_model = profile.get("reference_model") + model_names = tuple(dict.fromkeys((*declared_models, *available_models, *missing_models))) + self.preparation_models_model.set_rows( + ( + { + "name": name, + "available": name in available_models, + "declared": name in declared_models, + "missing": name in missing_models, + "reference": name == reference_model, + } + for name in model_names + ), + available=True, + ) + for key, model in ( + ("numeric_candidates", self.preparation_numeric_candidates_model), + ("target_candidates", self.preparation_target_candidates_model), + ("categorical_candidates", self.preparation_categorical_candidates_model), + ("auxiliary_candidates", self.preparation_auxiliary_candidates_model), + ): + model.set_rows(_preparation_field_rows(profile[key]), available=True) + observed = cast(dict[str, list[str]], profile["observed_category_values"]) + self.preparation_observed_categories_model.set_rows( + ( + {"field": field, "values": list(values), "count": len(values)} + for field, values in observed.items() + ), + available=True, + ) + self.preparation_derived_features_model.set_rows( + _preparation_derived_rows(profile["derived_features"]), + available=True, + ) + reference_context = cast(dict[str, Any], profile["reference_context"]) + self.preparation_reference_contexts_model.set_rows( + _preparation_reference_context_rows(reference_context["contexts"]), + available=True, + ) + def _update_workspace_sources_model(self) -> None: rows = ( { @@ -996,6 +1432,287 @@ def _mapping(value: object) -> dict[str, Any]: return cast(dict[str, Any], value) if isinstance(value, dict) else {} +def _profile_value(profile: dict[str, Any] | None, key: str) -> object: + return None if profile is None else profile.get(key) + + +def _profile_nested_value( + profile: dict[str, Any] | None, + section: str, + key: str, +) -> object: + value = _profile_value(profile, section) + return value.get(key) if isinstance(value, dict) else None + + +def _validated_preparation_profile( + value: dict[str, Any], + *, + source: Path, + source_kind: str, + revision: str, +) -> dict[str, Any]: + schema_version = value.get("profile_schema_version") + if schema_version != 1 or isinstance(schema_version, bool): + raise ValueError("unsupported profile schema version") + if value.get("source_path") != str(source): + raise ValueError("source path does not match the inspection") + if value.get("source_kind") != source_kind: + raise ValueError("source kind does not match the eligibility descriptor") + if value.get("inspection_revision") != revision: + raise ValueError("revision does not match the inspection") + + source_identity = _required_profile_mapping(value, "source_identity") + if source_identity.get("source_kind") != source_kind: + raise ValueError("source identity kind is inconsistent") + completion = _required_profile_mapping(value, "completion") + if not _nonempty_string(completion.get("status")): + raise ValueError("completion status must be non-empty text") + if not isinstance(completion.get("partial"), bool): + raise ValueError("completion partial state must be boolean") + _profile_string_list(completion, "included_child_models") + _profile_string_list(completion, "missing_child_models") + + available_models = _profile_string_list(value, "available_models") + declared_models = _profile_string_list(value, "declared_models") + reference_model = value.get("reference_model") + if reference_model is not None and not _nonempty_string(reference_model): + raise ValueError("reference model must be non-empty text or null") + if ( + isinstance(reference_model, str) + and reference_model not in available_models + and reference_model not in declared_models + ): + raise ValueError("reference model is absent from the source model lists") + + numeric = _validated_field_candidates(value.get("numeric_candidates"), "numeric") + targets = _validated_field_candidates(value.get("target_candidates"), "target") + if targets != numeric: + raise ValueError("target candidates must match numeric candidates") + _validated_field_candidates(value.get("categorical_candidates"), "categorical") + _validated_field_candidates(value.get("auxiliary_candidates"), "auxiliary") + _validated_observed_categories(value.get("observed_category_values")) + _validated_derived_features(value.get("derived_features")) + _validated_model_holdout(value.get("model_holdout")) + _validated_reference_context(value.get("reference_context")) + return copy.deepcopy(value) + + +def _required_profile_mapping(mapping: dict[str, Any], key: str) -> dict[str, Any]: + value = mapping.get(key) + if not isinstance(value, dict): + raise ValueError(f"{key} must be a mapping") + return cast(dict[str, Any], value) + + +def _profile_string_list(mapping: dict[str, Any], key: str) -> list[str]: + value = mapping.get(key) + if ( + not isinstance(value, list) + or not all(_nonempty_string(item) for item in value) + or len(set(cast(list[str], value))) != len(value) + ): + raise ValueError(f"{key} must be a unique list of non-empty strings") + return cast(list[str], value) + + +def _nonempty_string(value: object) -> bool: + return isinstance(value, str) and bool(value) + + +def _validated_field_candidates(value: object, label: str) -> list[dict[str, Any]]: + if not isinstance(value, list): + raise ValueError(f"{label} candidates must be a list") + names: set[str] = set() + normalized: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + raise ValueError(f"{label} candidate entries must be mappings") + candidate = cast(dict[str, Any], item) + name = candidate.get("name") + if not _nonempty_string(name) or name in names: + raise ValueError(f"{label} candidate names must be non-empty and unique") + if not _nonempty_string(candidate.get("column")): + raise ValueError(f"{label} candidate columns must be non-empty text") + unit = candidate.get("unit") + if unit is not None and not isinstance(unit, str): + raise ValueError(f"{label} candidate units must be text or null") + if candidate.get("source") not in { + "coordinate", + "property", + "categorical", + "auxiliary", + }: + raise ValueError(f"{label} candidate source is unknown") + if not isinstance(candidate.get("reference_dependent"), bool): + raise ValueError(f"{label} candidate reference state must be boolean") + names.add(cast(str, name)) + normalized.append(candidate) + return normalized + + +def _validated_observed_categories(value: object) -> None: + if not isinstance(value, dict): + raise ValueError("observed category values must be a mapping") + for field, values in value.items(): + if not _nonempty_string(field): + raise ValueError("observed category fields must be non-empty text") + if ( + not isinstance(values, list) + or not all(isinstance(item, str) for item in values) + or len(set(values)) != len(values) + ): + raise ValueError("observed category values must be unique strings") + + +def _validated_derived_features(value: object) -> None: + if not isinstance(value, list): + raise ValueError("derived features must be a list") + names: set[str] = set() + for item in value: + if not isinstance(item, dict): + raise ValueError("derived feature entries must be mappings") + feature = cast(dict[str, Any], item) + name = feature.get("name") + if not _nonempty_string(name) or name in names: + raise ValueError("derived feature names must be non-empty and unique") + status = feature.get("status") + available = feature.get("available") + ready_rows = feature.get("ready_row_count") + source_rows = feature.get("source_row_count") + reason = feature.get("reason") + if status not in {"ready", "partial", "unavailable"}: + raise ValueError("derived feature status is unknown") + if not isinstance(available, bool) or not isinstance(reason, str): + raise ValueError("derived feature availability is malformed") + if not _nonnegative_int(ready_rows) or not _nonnegative_int(source_rows): + raise ValueError("derived feature row counts must be non-negative integers") + assert isinstance(ready_rows, int) + assert isinstance(source_rows, int) + if ready_rows > source_rows: + raise ValueError("derived feature ready rows exceed source rows") + if status == "ready" and not ( + available and source_rows > 0 and ready_rows == source_rows and not reason + ): + raise ValueError("ready derived feature state is inconsistent") + if status == "partial" and not (available and 0 < ready_rows < source_rows and reason): + raise ValueError("partial derived feature state is inconsistent") + if status == "unavailable" and not (not available and ready_rows == 0 and reason): + raise ValueError("unavailable derived feature state is inconsistent") + _profile_string_list(feature, "reason_codes") + _profile_string_list(feature, "missing_dependencies") + _profile_string_list(feature, "dependencies") + if not _nonempty_string(feature.get("unit")): + raise ValueError("derived feature unit must be non-empty text") + names.add(cast(str, name)) + + +def _nonnegative_int(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _validated_model_holdout(value: object) -> None: + if not isinstance(value, dict): + raise ValueError("model holdout state must be a mapping") + available = value.get("available") + reason = value.get("reason") + if not isinstance(available, bool) or not isinstance(reason, str): + raise ValueError("model holdout state is malformed") + if available == bool(reason): + raise ValueError("model holdout reason is inconsistent with availability") + + +def _validated_reference_context(value: object) -> None: + if not isinstance(value, dict): + raise ValueError("reference context must be a mapping") + compatible = value.get("compatible") + compatible_context = value.get("compatible_context") + reason_code = value.get("reason_code") + reason = value.get("reason") + contexts = value.get("contexts") + if ( + not isinstance(compatible, bool) + or not isinstance(reason_code, str) + or not isinstance(reason, str) + or not isinstance(contexts, list) + ): + raise ValueError("reference context state is malformed") + if compatible: + if not isinstance(compatible_context, dict) or reason_code or reason: + raise ValueError("compatible reference context is inconsistent") + for key in ("reference_state_policy", "backend", "backend_model"): + if not _nonempty_string(compatible_context.get(key)): + raise ValueError("compatible reference context is incomplete") + elif compatible_context is not None or not reason_code or not reason: + raise ValueError("incompatible reference context is inconsistent") + for item in contexts: + if not isinstance(item, dict): + raise ValueError("reference context entries must be mappings") + context = cast(dict[str, Any], item) + if not _nonempty_string(context.get("artifact")) or not _nonempty_string( + context.get("run_id") + ): + raise ValueError("reference context identity is incomplete") + for key in ( + "backend", + "backend_model", + "reference_state_policy", + "reference_state_backend_model", + ): + item_value = context.get(key) + if item_value is not None and not _nonempty_string(item_value): + raise ValueError("reference context text values must be non-empty or null") + targets = context.get("reference_state_targets") + if not isinstance(targets, list) or not all(isinstance(item, str) for item in targets): + raise ValueError("reference-state targets must be strings") + + +def _preparation_field_rows(value: object) -> list[dict[str, object]]: + return [ + { + "name": item["name"], + "column": item["column"], + "unit": item["unit"] or "", + "source": item["source"], + "referenceDependent": item["reference_dependent"], + } + for item in cast(list[dict[str, Any]], value) + ] + + +def _preparation_derived_rows(value: object) -> list[dict[str, object]]: + return [ + { + "name": item["name"], + "status": item["status"], + "available": item["available"], + "readyRowCount": item["ready_row_count"], + "sourceRowCount": item["source_row_count"], + "reason": item["reason"], + "reasonCodes": list(item["reason_codes"]), + "missingDependencies": list(item["missing_dependencies"]), + "dependencies": list(item["dependencies"]), + "unit": item["unit"], + } + for item in cast(list[dict[str, Any]], value) + ] + + +def _preparation_reference_context_rows(value: object) -> list[dict[str, object]]: + return [ + { + "artifact": item["artifact"], + "runId": item["run_id"], + "backend": item["backend"] or "", + "backendModel": item["backend_model"] or "", + "referenceStatePolicy": item["reference_state_policy"] or "", + "referenceStateBackendModel": item["reference_state_backend_model"] or "", + "referenceStateTargets": list(item["reference_state_targets"]), + } + for item in cast(list[dict[str, Any]], value) + ] + + def _display(value: object) -> str: if value is None: return "" diff --git a/src/carnopy/app/preparation_audit.py b/src/carnopy/app/preparation_audit.py new file mode 100644 index 0000000..c141007 --- /dev/null +++ b/src/carnopy/app/preparation_audit.py @@ -0,0 +1,1129 @@ +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + +QUALITY_OVERVIEW_ROLES = ( + "status", + "eligibleRowCount", + "excludedRowCount", + "eligibleRowCountAvailable", + "excludedRowCountAvailable", + "recordedFlagCount", + "inspectedFlagCount", + "recordedFlagCountAvailable", + "inspectedFlagCountAvailable", + "flagCountMatches", + "errorCount", + "scenarioStatus", + "matrixStatus", + "baselineStatus", + "duplicateStatus", + "gridStatus", +) +SCENARIO_ROLES = ( + "name", + "kind", + "order", + "rowCount", + "partitionCount", + "transformationCount", + "leakageAvailable", +) +PARTITION_ROLES = ("scenario", "partition", "order", "rowCount") +LEAKAGE_ROLES = ( + "scenario", + "identityColumn", + "duplicateStateGroupCount", + "crossPartitionGroupCount", +) +DUPLICATE_STATE_ROLES = ( + "status", + "groupColumns", + "identityColumn", + "countsAvailable", + "duplicateGroupCount", + "duplicateRowCount", + "conflictingTargetGroupCount", +) +GRID_GROUP_ROLES = ( + "order", + "sourceRunId", + "sourceFluid", + "backendModel", + "rowCount", + "expectedCells", + "observedCells", + "missingCells", + "coverageFraction", + "coverageAvailable", + "repeatedCellCount", + "repeatedRowCount", + "phaseBoundaryStatus", + "multiPhaseCellCount", + "transitionEdgeCount", +) +GRID_SPACING_ROLES = ( + "groupOrder", + "coordinate", + "order", + "levelCount", + "minimum", + "minimumAvailable", + "maximum", + "maximumAvailable", + "spacingCount", + "minimumSpacing", + "minimumSpacingAvailable", + "maximumSpacing", + "maximumSpacingAvailable", + "medianSpacing", + "medianSpacingAvailable", + "spacingRatio", + "spacingRatioAvailable", + "uniformSpacing", + "uniformSpacingAvailable", +) +GRID_PHASE_ROLES = ("groupOrder", "phase", "count") +MATRIX_CHECK_ROLES = ( + "scenario", + "fitPartition", + "order", + "status", + "rowCount", + "featureCount", + "targetCount", + "correlationThreshold", + "correlationThresholdAvailable", + "nearConstantThreshold", + "nearConstantThresholdAvailable", + "numericalRank", + "numericalRankAvailable", + "featureRankFraction", + "featureRankFractionAvailable", + "effectiveRank", + "effectiveRankAvailable", + "effectiveRankFraction", + "effectiveRankFractionAvailable", + "conditionNumber", + "conditionNumberAvailable", + "conditionNumberInfinite", + "rankTolerance", + "rankToleranceAvailable", + "rankToleranceDefinition", +) +MATRIX_FEATURE_FLAG_ROLES = ( + "scenario", + "fitPartition", + "kind", + "field", + "order", + "relativeSpread", + "relativeSpreadAvailable", +) +SINGULAR_VALUE_ROLES = ( + "scenario", + "fitPartition", + "order", + "singularValue", + "explainedVarianceRatio", +) +CORRELATED_PAIR_ROLES = ( + "scenario", + "fitPartition", + "order", + "left", + "right", + "correlation", +) +FEATURE_TARGET_CORRELATION_ROLES = ( + "scenario", + "fitPartition", + "order", + "feature", + "target", + "correlation", +) +BASELINE_CHECK_ROLES = ( + "scenario", + "order", + "status", + "library", + "libraryVersion", + "featureCount", + "targetCount", + "trainRowCount", + "trainRowCountAvailable", + "evaluationPartitionCount", + "evaluationRowCount", + "completedModelCount", + "failedModelCount", + "policy", +) +BASELINE_METRIC_ROLES = ( + "scenario", + "model", + "target", + "partition", + "order", + "meanAbsoluteError", + "rootMeanSquaredError", + "rSquared", + "rSquaredAvailable", + "actualMinimum", + "actualMaximum", + "predictionMinimum", + "predictionMaximum", +) +BASELINE_FAILURE_ROLES = ( + "scenario", + "model", + "target", + "order", + "errorType", + "message", +) + +_PARTITION_ORDER = {"all": 0, "train": 1, "validation": 2, "test": 3} +_GRID_COORDINATE_ORDER = {"source_temperature_K": 0, "source_pressure_Pa": 1} + + +@dataclass(frozen=True) +class PreparationAuditProjection: + """Detached typed rows from one finalized Preparation inspection.""" + + available: bool + quality_status: str + quality_errors: tuple[str, ...] + scenario_evidence_available: bool + leakage_evidence_available: bool + duplicate_state_evidence_available: bool + grid_evidence_available: bool + matrix_evidence_available: bool + baseline_evidence_available: bool + quality_overview: tuple[dict[str, object], ...] + scenarios: tuple[dict[str, object], ...] + partitions: tuple[dict[str, object], ...] + leakage_audits: tuple[dict[str, object], ...] + duplicate_state_checks: tuple[dict[str, object], ...] + grid_groups: tuple[dict[str, object], ...] + grid_spacing: tuple[dict[str, object], ...] + grid_phase_counts: tuple[dict[str, object], ...] + matrix_checks: tuple[dict[str, object], ...] + matrix_feature_flags: tuple[dict[str, object], ...] + singular_values: tuple[dict[str, object], ...] + correlated_feature_pairs: tuple[dict[str, object], ...] + feature_target_correlations: tuple[dict[str, object], ...] + baseline_checks: tuple[dict[str, object], ...] + baseline_metrics: tuple[dict[str, object], ...] + baseline_failures: tuple[dict[str, object], ...] + + @classmethod + def from_worker_payload( + cls, + payload: Mapping[str, object], + ) -> PreparationAuditProjection: + if payload.get("source_kind") != "preparation": + raise ValueError("Preparation audit source kind must be preparation") + summary = _mapping(payload.get("summary"), "inspection summary") + quality = _optional_mapping(summary.get("quality"), "quality evidence") + errors = tuple(_optional_ordered_text_list(quality.get("errors"), "quality errors")) + quality_summary = _optional_mapping(quality.get("summary"), "quality summary") + quality_status = _status(quality_summary.get("status"), default="absent") + + scenario_evidence, scenario_evidence_available = _scenario_evidence(payload) + raw_scenarios = summary.get("scenarios") + scenarios, partitions, leakage_audits, scenario_status = _scenario_rows( + raw_scenarios, + scenario_evidence, + evidence_available=scenario_evidence_available, + ) + raw_duplicates = quality_summary.get("duplicate_state_candidates") + duplicate_rows, duplicate_status = _duplicate_state_rows(raw_duplicates) + raw_grid = quality_summary.get("structured_grid") + grid_groups, grid_spacing, grid_phases, grid_status = _grid_rows(raw_grid) + raw_matrix = quality_summary.get("matrix_diagnostics") + ( + matrix_checks, + matrix_feature_flags, + singular_values, + correlated_pairs, + target_correlations, + matrix_status, + ) = _matrix_rows(raw_matrix) + raw_baseline = quality_summary.get("baseline_diagnostics") + baseline_checks, baseline_metrics, baseline_failures, baseline_status = _baseline_rows( + raw_baseline + ) + + eligible, eligible_available = _optional_nonnegative_integer( + _optional_mapping(quality_summary.get("row_counts"), "quality row counts").get( + "eligible" + ), + "eligible row count", + ) + excluded, excluded_available = _optional_nonnegative_integer( + _optional_mapping(quality_summary.get("row_counts"), "quality row counts").get( + "excluded" + ), + "excluded row count", + ) + expected_eligible_rows = _validate_inspection_row_counts( + summary, + eligible=eligible, + eligible_available=eligible_available, + excluded=excluded, + excluded_available=excluded_available, + ) + _validate_cross_section_identities( + scenarios=scenarios, + leakage_audits=leakage_audits, + matrix_checks=matrix_checks, + baseline_checks=baseline_checks, + expected_eligible_rows=expected_eligible_rows, + ) + quality_flags = _optional_mapping(quality_summary.get("quality_flags"), "quality flags") + recorded_flags, recorded_available = _optional_nonnegative_integer( + quality_flags.get("row_count"), "recorded quality-flag count" + ) + inspected_flags, inspected_available = _optional_nonnegative_integer( + quality_summary.get("flags_row_count"), "inspected quality-flag count" + ) + counts_match = ( + recorded_flags == inspected_flags + if recorded_available and inspected_available + else False + ) + overview = ( + { + "status": quality_status, + "eligibleRowCount": eligible, + "excludedRowCount": excluded, + "eligibleRowCountAvailable": eligible_available, + "excludedRowCountAvailable": excluded_available, + "recordedFlagCount": recorded_flags, + "inspectedFlagCount": inspected_flags, + "recordedFlagCountAvailable": recorded_available, + "inspectedFlagCountAvailable": inspected_available, + "flagCountMatches": counts_match, + "errorCount": len(errors), + "scenarioStatus": scenario_status, + "matrixStatus": matrix_status, + "baselineStatus": baseline_status, + "duplicateStatus": duplicate_status, + "gridStatus": grid_status, + }, + ) + available = bool( + quality_status not in {"absent", "unavailable"} + or errors + or scenarios + or duplicate_rows + or grid_groups + or matrix_checks + or baseline_checks + or recorded_available + or inspected_available + ) + return cls( + available=available, + quality_status=quality_status, + quality_errors=errors, + scenario_evidence_available=raw_scenarios is not None, + leakage_evidence_available=scenario_evidence_available, + duplicate_state_evidence_available=raw_duplicates is not None, + grid_evidence_available=raw_grid is not None, + matrix_evidence_available=raw_matrix is not None, + baseline_evidence_available=raw_baseline is not None, + quality_overview=overview, + scenarios=scenarios, + partitions=partitions, + leakage_audits=leakage_audits, + duplicate_state_checks=duplicate_rows, + grid_groups=grid_groups, + grid_spacing=grid_spacing, + grid_phase_counts=grid_phases, + matrix_checks=matrix_checks, + matrix_feature_flags=matrix_feature_flags, + singular_values=singular_values, + correlated_feature_pairs=correlated_pairs, + feature_target_correlations=target_correlations, + baseline_checks=baseline_checks, + baseline_metrics=baseline_metrics, + baseline_failures=baseline_failures, + ) + + +def _scenario_evidence( + payload: Mapping[str, object], +) -> tuple[dict[str, Mapping[str, object]], bool]: + audit = payload.get("preparation_audit") + if audit is None: + return {}, False + audit_mapping = _mapping(audit, "private audit evidence") + if audit_mapping.get("audit_schema_version") != 1: + raise ValueError("Preparation audit evidence has an unsupported schema version") + result: dict[str, Mapping[str, object]] = {} + for item in _mapping_list(audit_mapping.get("scenario_details"), "scenario details"): + name = _nonempty_text(item.get("name"), "scenario-detail name") + if name in result: + raise ValueError(f"Preparation audit scenario detail {name!r} is duplicated") + result[name] = item + return result, True + + +def _scenario_rows( + value: object, + evidence: Mapping[str, Mapping[str, object]], + *, + evidence_available: bool, +) -> tuple[ + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + str, +]: + if value is None: + if evidence: + raise ValueError("Preparation audit scenario details have no scenario summary") + return (), (), (), "not_configured" + summary = _mapping(value, "scenario summary") + status = _status(summary.get("status"), default="unreported") + items = _mapping_list(summary.get("scenarios"), "scenarios") + declared_count = _nonnegative_integer(summary.get("scenario_count"), "scenario count") + if declared_count != len(items): + raise ValueError("Preparation audit scenario count is inconsistent") + rows: list[dict[str, object]] = [] + partitions: list[dict[str, object]] = [] + leakage: list[dict[str, object]] = [] + names: set[str] = set() + for order, item in enumerate(items): + name = _nonempty_text(item.get("name"), "scenario name") + if name in names: + raise ValueError(f"Preparation audit scenario {name!r} is duplicated") + names.add(name) + counts = _mapping(item.get("partition_counts"), f"partitions for scenario {name}") + partition_rows = _partition_rows(name, counts) + partitions.extend(partition_rows) + transformations = _mapping_list(item.get("transformations"), "scenario transformations") + detail = evidence.get(name) + leakage_available = detail is not None and detail.get("state_leakage") is not None + if leakage_available: + assert detail is not None + leakage.append(_leakage_row(name, detail.get("state_leakage"))) + rows.append( + { + "name": name, + "kind": _nonempty_text(item.get("kind"), "scenario kind"), + "order": order, + "rowCount": sum(cast(int, row["rowCount"]) for row in partition_rows), + "partitionCount": len(partition_rows), + "transformationCount": len(transformations), + "leakageAvailable": leakage_available, + } + ) + unknown_details = sorted(set(evidence) - names) + missing_details = sorted(names - set(evidence)) if evidence_available else [] + if unknown_details or missing_details: + raise ValueError( + "Preparation audit scenario details do not match the scenario summary: " + + ", ".join([*unknown_details, *missing_details]) + ) + declared_partitions = _nonnegative_integer( + summary.get("partition_count"), "scenario partition count" + ) + if declared_partitions != len(partitions): + raise ValueError("Preparation audit scenario partition count is inconsistent") + return tuple(rows), tuple(partitions), tuple(leakage), status + + +def _partition_rows( + scenario: str, + counts: Mapping[str, object], +) -> tuple[dict[str, object], ...]: + values = [ + ( + _nonempty_text(name, "partition name"), + _nonnegative_integer(count, "partition row count"), + ) + for name, count in counts.items() + ] + values.sort(key=lambda item: (_PARTITION_ORDER.get(item[0], len(_PARTITION_ORDER)), item[0])) + return tuple( + {"scenario": scenario, "partition": name, "order": order, "rowCount": count} + for order, (name, count) in enumerate(values) + ) + + +def _leakage_row(scenario: str, value: object) -> dict[str, object]: + leakage = _mapping(value, f"leakage evidence for scenario {scenario}") + return { + "scenario": scenario, + "identityColumn": _nonempty_text(leakage.get("identity_column"), "leakage identity"), + "duplicateStateGroupCount": _nonnegative_integer( + leakage.get("duplicate_state_group_count"), "duplicate state group count" + ), + "crossPartitionGroupCount": _nonnegative_integer( + leakage.get("cross_partition_group_count"), "cross-partition group count" + ), + } + + +def _duplicate_state_rows(value: object) -> tuple[tuple[dict[str, object], ...], str]: + if value is None: + return (), "unreported" + summary = _mapping(value, "duplicate-state evidence") + status = _status(summary.get("status"), default="unreported") + row = { + "status": status, + "groupColumns": _optional_string_list(summary.get("group_columns"), "duplicate groups"), + "identityColumn": _optional_text(summary.get("identity_column"), "duplicate identity"), + "countsAvailable": status == "completed", + "duplicateGroupCount": _optional_count_value( + summary.get("duplicate_group_count"), "duplicate group count" + ), + "duplicateRowCount": _optional_count_value( + summary.get("duplicate_row_count"), "duplicate row count" + ), + "conflictingTargetGroupCount": _optional_count_value( + summary.get("conflicting_target_group_count"), "conflicting target group count" + ), + } + return (row,), status + + +def _grid_rows( + value: object, +) -> tuple[ + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + str, +]: + if value is None: + return (), (), (), "unreported" + summary = _mapping(value, "structured-grid evidence") + status = _status(summary.get("status"), default="unreported") + raw_groups = summary.get("groups") + if status != "completed": + if raw_groups is not None and raw_groups != []: + raise ValueError("Preparation audit skipped structured grid must not contain groups") + return (), (), (), status + groups = _mapping_list(raw_groups, "structured-grid groups") + group_rows: list[dict[str, object]] = [] + spacing_rows: list[dict[str, object]] = [] + phase_rows: list[dict[str, object]] = [] + for order, item in enumerate(groups): + identity = _mapping(item.get("group"), "structured-grid group identity") + coverage, coverage_available = _optional_number( + item.get("coverage_fraction"), "grid coverage fraction" + ) + phase = _mapping(item.get("phase_boundaries"), "grid phase-boundary evidence") + phase_status = _status(phase.get("status"), default="unreported") + group_rows.append( + { + "order": order, + "sourceRunId": _nonempty_text(identity.get("source_run_id"), "grid run ID"), + "sourceFluid": _nonempty_text(identity.get("source_fluid"), "grid fluid"), + "backendModel": _nonempty_text(identity.get("backend_model"), "grid model"), + "rowCount": _nonnegative_integer(item.get("row_count"), "grid row count"), + "expectedCells": _nonnegative_integer( + item.get("expected_cells"), "expected grid cells" + ), + "observedCells": _nonnegative_integer( + item.get("observed_cells"), "observed grid cells" + ), + "missingCells": _nonnegative_integer( + item.get("missing_cells"), "missing grid cells" + ), + "coverageFraction": coverage, + "coverageAvailable": coverage_available, + "repeatedCellCount": _nonnegative_integer( + item.get("repeated_cell_count"), "repeated grid-cell count" + ), + "repeatedRowCount": _nonnegative_integer( + item.get("repeated_row_count"), "repeated grid-row count" + ), + "phaseBoundaryStatus": phase_status, + "multiPhaseCellCount": _optional_count_value( + phase.get("multi_phase_cell_count"), "multi-phase cell count" + ), + "transitionEdgeCount": _optional_count_value( + phase.get("transition_edge_count"), "phase-transition edge count" + ), + } + ) + spacing_rows.extend(_grid_spacing_rows(order, item.get("coordinate_spacing"))) + phase_counts = _optional_mapping(phase.get("phase_counts"), "grid phase counts") + phase_rows.extend( + { + "groupOrder": order, + "phase": _nonempty_text(name, "grid phase"), + "count": _nonnegative_integer(count, "grid phase count"), + } + for name, count in sorted(phase_counts.items()) + ) + return tuple(group_rows), tuple(spacing_rows), tuple(phase_rows), status + + +def _grid_spacing_rows(group_order: int, value: object) -> list[dict[str, object]]: + spacing = _mapping(value, "grid coordinate spacing") + coordinates = sorted( + spacing, + key=lambda name: (_GRID_COORDINATE_ORDER.get(name, len(_GRID_COORDINATE_ORDER)), name), + ) + rows: list[dict[str, object]] = [] + for order, coordinate in enumerate(coordinates): + item = _mapping(spacing[coordinate], f"spacing for {coordinate}") + minimum, minimum_available = _optional_number(item.get("minimum"), "grid minimum") + maximum, maximum_available = _optional_number(item.get("maximum"), "grid maximum") + minimum_spacing, minimum_spacing_available = _optional_number( + item.get("minimum_spacing"), "minimum grid spacing" + ) + maximum_spacing, maximum_spacing_available = _optional_number( + item.get("maximum_spacing"), "maximum grid spacing" + ) + median_spacing, median_spacing_available = _optional_number( + item.get("median_spacing"), "median grid spacing" + ) + ratio, ratio_available = _optional_number( + item.get("maximum_to_minimum_spacing_ratio"), "grid spacing ratio" + ) + uniform, uniform_available = _optional_boolean( + item.get("uniform_spacing"), "uniform grid-spacing state" + ) + rows.append( + { + "groupOrder": group_order, + "coordinate": coordinate, + "order": order, + "levelCount": _nonnegative_integer(item.get("level_count"), "grid level count"), + "minimum": minimum, + "minimumAvailable": minimum_available, + "maximum": maximum, + "maximumAvailable": maximum_available, + "spacingCount": _nonnegative_integer( + item.get("spacing_count"), "grid spacing count" + ), + "minimumSpacing": minimum_spacing, + "minimumSpacingAvailable": minimum_spacing_available, + "maximumSpacing": maximum_spacing, + "maximumSpacingAvailable": maximum_spacing_available, + "medianSpacing": median_spacing, + "medianSpacingAvailable": median_spacing_available, + "spacingRatio": ratio, + "spacingRatioAvailable": ratio_available, + "uniformSpacing": uniform, + "uniformSpacingAvailable": uniform_available, + } + ) + return rows + + +def _matrix_rows( + value: object, +) -> tuple[ + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + str, +]: + if value is None: + return (), (), (), (), (), "unreported" + summary = _mapping(value, "matrix diagnostics") + status = _status(summary.get("status"), default="unreported") + fits = _mapping_list(summary.get("fits"), "matrix diagnostic fits") + checks: list[dict[str, object]] = [] + flags: list[dict[str, object]] = [] + singular_rows: list[dict[str, object]] = [] + pair_rows: list[dict[str, object]] = [] + target_rows: list[dict[str, object]] = [] + for order, fit in enumerate(fits): + scenario = _nullable_text(fit.get("scenario"), "matrix scenario") + partition = _nonempty_text(fit.get("fit_partition"), "matrix fit partition") + features = _string_list(fit.get("feature_columns"), "matrix feature columns") + targets = _string_list(fit.get("target_columns"), "matrix target columns") + numerical_rank, rank_available = _optional_nonnegative_integer( + fit.get("numerical_rank"), "matrix numerical rank" + ) + feature_fraction, feature_fraction_available = _optional_number( + fit.get("feature_rank_fraction"), "feature-rank fraction" + ) + effective_rank, effective_rank_available = _optional_number( + fit.get("effective_rank"), "effective rank" + ) + effective_fraction, effective_fraction_available = _optional_number( + fit.get("effective_rank_fraction"), "effective-rank fraction" + ) + condition, condition_available = _optional_number( + fit.get("condition_number"), "condition number" + ) + tolerance, tolerance_available = _optional_number( + fit.get("rank_tolerance"), "rank tolerance" + ) + correlation_threshold, correlation_threshold_available = _optional_number( + fit.get("correlation_threshold"), "correlation threshold" + ) + near_constant_threshold, near_constant_threshold_available = _optional_number( + fit.get("near_constant_relative_spread_threshold"), + "near-constant threshold", + ) + checks.append( + { + "scenario": scenario, + "fitPartition": partition, + "order": order, + "status": _status(fit.get("status"), default="unreported"), + "rowCount": _nonnegative_integer(fit.get("row_count"), "matrix row count"), + "featureCount": len(features), + "targetCount": len(targets), + "correlationThreshold": correlation_threshold, + "correlationThresholdAvailable": correlation_threshold_available, + "nearConstantThreshold": near_constant_threshold, + "nearConstantThresholdAvailable": near_constant_threshold_available, + "numericalRank": numerical_rank, + "numericalRankAvailable": rank_available, + "featureRankFraction": feature_fraction, + "featureRankFractionAvailable": feature_fraction_available, + "effectiveRank": effective_rank, + "effectiveRankAvailable": effective_rank_available, + "effectiveRankFraction": effective_fraction, + "effectiveRankFractionAvailable": effective_fraction_available, + "conditionNumber": condition, + "conditionNumberAvailable": condition_available, + "conditionNumberInfinite": _optional_boolean_value( + fit.get("condition_number_is_infinite"), "infinite condition-number state" + ), + "rankTolerance": tolerance, + "rankToleranceAvailable": tolerance_available, + "rankToleranceDefinition": _optional_text( + fit.get("rank_tolerance_definition"), "rank-tolerance definition" + ), + } + ) + flags.extend(_matrix_feature_rows(scenario, partition, fit)) + singular = _optional_number_list(fit.get("singular_values"), "singular values") + explained = _optional_number_list( + fit.get("explained_variance_ratio"), "explained-variance ratios" + ) + if len(singular) != len(explained): + raise ValueError("Preparation audit singular values and variance ratios differ") + singular_rows.extend( + { + "scenario": scenario, + "fitPartition": partition, + "order": index, + "singularValue": singular_value, + "explainedVarianceRatio": explained[index], + } + for index, singular_value in enumerate(singular) + ) + pair_rows.extend( + _correlated_pair_row(scenario, partition, index, item) + for index, item in enumerate( + _optional_mapping_list( + fit.get("highly_correlated_feature_pairs"), "correlated feature pairs" + ) + ) + ) + target_rows.extend( + _target_correlation_row(scenario, partition, index, item) + for index, item in enumerate( + _optional_mapping_list( + fit.get("feature_target_correlations"), "feature-target correlations" + ) + ) + ) + return ( + tuple(checks), + tuple(flags), + tuple(singular_rows), + tuple(pair_rows), + tuple(target_rows), + status, + ) + + +def _matrix_feature_rows( + scenario: str, + partition: str, + fit: Mapping[str, object], +) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for kind, key in ( + ("constant_feature", "constant_feature_columns"), + ("constant_target", "constant_target_columns"), + ): + rows.extend( + { + "scenario": scenario, + "fitPartition": partition, + "kind": kind, + "field": field, + "order": len(rows), + "relativeSpread": 0.0, + "relativeSpreadAvailable": False, + } + for field in _optional_string_list(fit.get(key), kind.replace("_", " ")) + ) + for item in _optional_mapping_list( + fit.get("near_constant_feature_columns"), "near-constant features" + ): + rows.append( + { + "scenario": scenario, + "fitPartition": partition, + "kind": "near_constant_feature", + "field": _nonempty_text(item.get("field"), "near-constant field"), + "order": len(rows), + "relativeSpread": _number(item.get("relative_spread"), "relative spread"), + "relativeSpreadAvailable": True, + } + ) + return rows + + +def _correlated_pair_row( + scenario: str, + partition: str, + order: int, + item: Mapping[str, object], +) -> dict[str, object]: + return { + "scenario": scenario, + "fitPartition": partition, + "order": order, + "left": _nonempty_text(item.get("left"), "correlated-pair left field"), + "right": _nonempty_text(item.get("right"), "correlated-pair right field"), + "correlation": _number(item.get("correlation"), "feature correlation"), + } + + +def _target_correlation_row( + scenario: str, + partition: str, + order: int, + item: Mapping[str, object], +) -> dict[str, object]: + return { + "scenario": scenario, + "fitPartition": partition, + "order": order, + "feature": _nonempty_text(item.get("feature"), "correlation feature"), + "target": _nonempty_text(item.get("target"), "correlation target"), + "correlation": _number(item.get("correlation"), "feature-target correlation"), + } + + +def _baseline_rows( + value: object, +) -> tuple[ + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + str, +]: + if value is None: + return (), (), (), "unreported" + summary = _mapping(value, "baseline diagnostics") + status = _status(summary.get("status"), default="unreported") + fits = _mapping_list(summary.get("fits"), "baseline diagnostic fits") + checks: list[dict[str, object]] = [] + metrics: list[dict[str, object]] = [] + failures: list[dict[str, object]] = [] + for order, fit in enumerate(fits): + scenario = _nonempty_text(fit.get("scenario"), "baseline scenario") + feature_columns = _string_list(fit.get("feature_columns"), "baseline feature columns") + target_columns = _string_list(fit.get("target_columns"), "baseline target columns") + evaluations = _optional_mapping( + fit.get("evaluation_row_counts"), "baseline evaluation row counts" + ) + evaluation_counts = [ + _nonnegative_integer(count, f"baseline {partition} row count") + for partition, count in sorted( + evaluations.items(), + key=lambda item: ( + _PARTITION_ORDER.get(item[0], len(_PARTITION_ORDER)), + item[0], + ), + ) + ] + models = _optional_mapping_list(fit.get("models"), "baseline models") + train_rows, train_rows_available = _optional_nonnegative_integer( + fit.get("train_row_count"), "baseline train row count" + ) + completed_count = 0 + failure_count = 0 + metric_order = 0 + for model in models: + model_name = _nonempty_text(model.get("model"), "baseline model") + target = _nonempty_text(model.get("target"), "baseline target") + model_status = _status(model.get("status"), default="unreported") + if model_status == "completed": + completed_count += 1 + model_metrics = _mapping(model.get("metrics"), "baseline metrics") + if set(model_metrics) != set(evaluations): + raise ValueError( + "Preparation audit baseline metric partitions do not match " + "evaluation row counts" + ) + for partition, raw_metrics in sorted( + model_metrics.items(), + key=lambda item: ( + _PARTITION_ORDER.get(item[0], len(_PARTITION_ORDER)), + item[0], + ), + ): + metrics.append( + _baseline_metric_row( + scenario, + model_name, + target, + partition, + metric_order, + raw_metrics, + ) + ) + metric_order += 1 + elif model_status == "failed": + failure_count += 1 + failures.append( + { + "scenario": scenario, + "model": model_name, + "target": target, + "order": len(failures), + "errorType": _nonempty_text( + model.get("error_type"), "baseline failure type" + ), + "message": _nonempty_text(model.get("message"), "baseline failure message"), + } + ) + else: + raise ValueError( + f"Preparation audit baseline model status {model_status!r} is invalid" + ) + checks.append( + { + "scenario": scenario, + "order": order, + "status": _status(fit.get("status"), default="unreported"), + "library": _optional_text(fit.get("library"), "baseline library"), + "libraryVersion": _optional_text( + fit.get("library_version"), "baseline library version" + ), + "featureCount": len(feature_columns), + "targetCount": len(target_columns), + "trainRowCount": train_rows, + "trainRowCountAvailable": train_rows_available, + "evaluationPartitionCount": len(evaluation_counts), + "evaluationRowCount": sum(evaluation_counts), + "completedModelCount": completed_count, + "failedModelCount": failure_count, + "policy": _optional_text(fit.get("policy"), "baseline policy"), + } + ) + return tuple(checks), tuple(metrics), tuple(failures), status + + +def _baseline_metric_row( + scenario: str, + model: str, + target: str, + partition: object, + order: int, + value: object, +) -> dict[str, object]: + item = _mapping(value, "baseline metric") + r_squared, r_squared_available = _optional_number(item.get("r_squared"), "R-squared") + return { + "scenario": scenario, + "model": model, + "target": target, + "partition": _nonempty_text(partition, "baseline metric partition"), + "order": order, + "meanAbsoluteError": _number(item.get("mean_absolute_error"), "mean absolute error"), + "rootMeanSquaredError": _number( + item.get("root_mean_squared_error"), "root mean squared error" + ), + "rSquared": r_squared, + "rSquaredAvailable": r_squared_available, + "actualMinimum": _number(item.get("actual_minimum"), "actual minimum"), + "actualMaximum": _number(item.get("actual_maximum"), "actual maximum"), + "predictionMinimum": _number(item.get("prediction_minimum"), "prediction minimum"), + "predictionMaximum": _number(item.get("prediction_maximum"), "prediction maximum"), + } + + +def _validate_inspection_row_counts( + summary: Mapping[str, object], + *, + eligible: int, + eligible_available: bool, + excluded: int, + excluded_available: bool, +) -> int | None: + inspection_counts = _optional_mapping(summary.get("row_counts"), "inspection row counts") + inspected_eligible = 0 + inspected_eligible_available = False + for key, report_value, available in ( + ("eligible", eligible, eligible_available), + ("excluded", excluded, excluded_available), + ): + inspected, inspected_available = _optional_nonnegative_integer( + inspection_counts.get(key), f"inspection {key} row count" + ) + if available and inspected_available and inspected != report_value: + raise ValueError(f"Preparation audit {key} row counts are inconsistent") + if key == "eligible": + inspected_eligible = inspected + inspected_eligible_available = inspected_available + if eligible_available: + return eligible + if inspected_eligible_available: + return inspected_eligible + return None + + +def _validate_cross_section_identities( + *, + scenarios: tuple[dict[str, object], ...], + leakage_audits: tuple[dict[str, object], ...], + matrix_checks: tuple[dict[str, object], ...], + baseline_checks: tuple[dict[str, object], ...], + expected_eligible_rows: int | None, +) -> None: + names = {cast(str, row["name"]) for row in scenarios} + if expected_eligible_rows is not None: + for row in scenarios: + if row["rowCount"] != expected_eligible_rows: + raise ValueError( + f"Preparation audit scenario {row['name']!r} does not contain every " + "eligible row" + ) + for label, rows in ( + ("leakage", leakage_audits), + ("matrix", matrix_checks), + ("baseline", baseline_checks), + ): + for row in rows: + scenario = row["scenario"] + if scenario and scenario not in names: + raise ValueError( + f"Preparation audit {label} scenario {scenario!r} is not finalized" + ) + + +def _mapping(value: object, label: str) -> Mapping[str, object]: + if not isinstance(value, Mapping) or not all(isinstance(key, str) for key in value): + raise ValueError(f"Preparation audit {label} must be a string-keyed mapping") + return cast(Mapping[str, object], value) + + +def _optional_mapping(value: object, label: str) -> Mapping[str, object]: + return {} if value is None else _mapping(value, label) + + +def _mapping_list(value: object, label: str) -> list[Mapping[str, object]]: + if not isinstance(value, list): + raise ValueError(f"Preparation audit {label} must be a list") + return [_mapping(item, f"{label} entry") for item in value] + + +def _optional_mapping_list(value: object, label: str) -> list[Mapping[str, object]]: + return [] if value is None else _mapping_list(value, label) + + +def _string_list(value: object, label: str) -> list[str]: + if ( + not isinstance(value, list) + or not all(isinstance(item, str) and item for item in value) + or len(set(value)) != len(value) + ): + raise ValueError(f"Preparation audit {label} must be unique non-empty text") + return list(cast(list[str], value)) + + +def _optional_string_list(value: object, label: str) -> list[str]: + return [] if value is None else _string_list(value, label) + + +def _optional_ordered_text_list(value: object, label: str) -> list[str]: + if value is None: + return [] + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + raise ValueError(f"Preparation audit {label} must be non-empty text") + return list(cast(list[str], value)) + + +def _optional_number_list(value: object, label: str) -> list[float]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError(f"Preparation audit {label} must be a list") + return [_number(item, label) for item in value] + + +def _nonempty_text(value: object, label: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError(f"Preparation audit {label} must be non-empty text") + return value + + +def _optional_text(value: object, label: str) -> str: + if value is None: + return "" + if not isinstance(value, str): + raise ValueError(f"Preparation audit {label} must be text or null") + return value + + +def _nullable_text(value: object, label: str) -> str: + return "" if value is None else _nonempty_text(value, label) + + +def _status(value: object, *, default: str) -> str: + return default if value is None else _nonempty_text(value, "status") + + +def _nonnegative_integer(value: object, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"Preparation audit {label} must be a non-negative integer") + return value + + +def _optional_nonnegative_integer(value: object, label: str) -> tuple[int, bool]: + return (0, False) if value is None else (_nonnegative_integer(value, label), True) + + +def _optional_count_value(value: object, label: str) -> int: + return _optional_nonnegative_integer(value, label)[0] + + +def _number(value: object, label: str) -> float: + if not isinstance(value, int | float) or isinstance(value, bool): + raise ValueError(f"Preparation audit {label} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"Preparation audit {label} must be finite") + return result + + +def _optional_number(value: object, label: str) -> tuple[float, bool]: + return (0.0, False) if value is None else (_number(value, label), True) + + +def _optional_boolean(value: object, label: str) -> tuple[bool, bool]: + if value is None: + return False, False + if not isinstance(value, bool): + raise ValueError(f"Preparation audit {label} must be boolean or null") + return value, True + + +def _optional_boolean_value(value: object, label: str) -> bool: + return _optional_boolean(value, label)[0] diff --git a/src/carnopy/app/preparation_audit_models.py b/src/carnopy/app/preparation_audit_models.py new file mode 100644 index 0000000..4c1e3ee --- /dev/null +++ b/src/carnopy/app/preparation_audit_models.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from PySide6.QtCore import Property, QObject, Signal + +from carnopy.app.inspection_models import InspectionListModel +from carnopy.app.preparation_audit import ( + BASELINE_CHECK_ROLES, + BASELINE_FAILURE_ROLES, + BASELINE_METRIC_ROLES, + CORRELATED_PAIR_ROLES, + DUPLICATE_STATE_ROLES, + FEATURE_TARGET_CORRELATION_ROLES, + GRID_GROUP_ROLES, + GRID_PHASE_ROLES, + GRID_SPACING_ROLES, + LEAKAGE_ROLES, + MATRIX_CHECK_ROLES, + MATRIX_FEATURE_FLAG_ROLES, + PARTITION_ROLES, + QUALITY_OVERVIEW_ROLES, + SCENARIO_ROLES, + SINGULAR_VALUE_ROLES, + PreparationAuditProjection, +) + + +class PreparationAuditModel(QObject): + """Own the fixed QML-safe models for one accepted Preparation audit.""" + + changed = Signal() + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._projection: PreparationAuditProjection | None = None + self.quality_overview = InspectionListModel(QUALITY_OVERVIEW_ROLES, self) + self.scenarios = InspectionListModel(SCENARIO_ROLES, self) + self.partitions = InspectionListModel(PARTITION_ROLES, self) + self.leakage_audits = InspectionListModel(LEAKAGE_ROLES, self) + self.duplicate_state_checks = InspectionListModel(DUPLICATE_STATE_ROLES, self) + self.grid_groups = InspectionListModel(GRID_GROUP_ROLES, self) + self.grid_spacing = InspectionListModel(GRID_SPACING_ROLES, self) + self.grid_phase_counts = InspectionListModel(GRID_PHASE_ROLES, self) + self.matrix_checks = InspectionListModel(MATRIX_CHECK_ROLES, self) + self.matrix_feature_flags = InspectionListModel(MATRIX_FEATURE_FLAG_ROLES, self) + self.singular_values = InspectionListModel(SINGULAR_VALUE_ROLES, self) + self.correlated_feature_pairs = InspectionListModel(CORRELATED_PAIR_ROLES, self) + self.feature_target_correlations = InspectionListModel( + FEATURE_TARGET_CORRELATION_ROLES, + self, + ) + self.baseline_checks = InspectionListModel(BASELINE_CHECK_ROLES, self) + self.baseline_metrics = InspectionListModel(BASELINE_METRIC_ROLES, self) + self.baseline_failures = InspectionListModel(BASELINE_FAILURE_ROLES, self) + + def replace(self, projection: PreparationAuditProjection) -> None: + self._projection = projection + rows = ( + (self.quality_overview, projection.quality_overview, projection.available), + (self.scenarios, projection.scenarios, projection.scenario_evidence_available), + (self.partitions, projection.partitions, projection.scenario_evidence_available), + ( + self.leakage_audits, + projection.leakage_audits, + projection.leakage_evidence_available, + ), + ( + self.duplicate_state_checks, + projection.duplicate_state_checks, + projection.duplicate_state_evidence_available, + ), + (self.grid_groups, projection.grid_groups, projection.grid_evidence_available), + (self.grid_spacing, projection.grid_spacing, projection.grid_evidence_available), + ( + self.grid_phase_counts, + projection.grid_phase_counts, + projection.grid_evidence_available, + ), + (self.matrix_checks, projection.matrix_checks, projection.matrix_evidence_available), + ( + self.matrix_feature_flags, + projection.matrix_feature_flags, + projection.matrix_evidence_available, + ), + ( + self.singular_values, + projection.singular_values, + projection.matrix_evidence_available, + ), + ( + self.correlated_feature_pairs, + projection.correlated_feature_pairs, + projection.matrix_evidence_available, + ), + ( + self.feature_target_correlations, + projection.feature_target_correlations, + projection.matrix_evidence_available, + ), + ( + self.baseline_checks, + projection.baseline_checks, + projection.baseline_evidence_available, + ), + ( + self.baseline_metrics, + projection.baseline_metrics, + projection.baseline_evidence_available, + ), + ( + self.baseline_failures, + projection.baseline_failures, + projection.baseline_evidence_available, + ), + ) + for model, values, available in rows: + model.set_rows(values, available=available) + self.changed.emit() + + def clear(self) -> None: + had_projection = self._projection is not None + self._projection = None + for model in self._models(): + model.clear() + if had_projection: + self.changed.emit() + + def _models(self) -> tuple[InspectionListModel, ...]: + return ( + self.quality_overview, + self.scenarios, + self.partitions, + self.leakage_audits, + self.duplicate_state_checks, + self.grid_groups, + self.grid_spacing, + self.grid_phase_counts, + self.matrix_checks, + self.matrix_feature_flags, + self.singular_values, + self.correlated_feature_pairs, + self.feature_target_correlations, + self.baseline_checks, + self.baseline_metrics, + self.baseline_failures, + ) + + def get_available(self) -> bool: + return bool(self._projection is not None and self._projection.available) + + available = Property(bool, get_available, notify=changed) + + def get_quality_status(self) -> str: + return "" if self._projection is None else self._projection.quality_status + + qualityStatus = Property(str, get_quality_status, notify=changed) + + def get_scenario_evidence_available(self) -> bool: + return bool(self._projection is not None and self._projection.scenario_evidence_available) + + scenarioEvidenceAvailable = Property( + bool, + get_scenario_evidence_available, + notify=changed, + ) + + def get_leakage_evidence_available(self) -> bool: + return bool(self._projection is not None and self._projection.leakage_evidence_available) + + leakageEvidenceAvailable = Property( + bool, + get_leakage_evidence_available, + notify=changed, + ) + + qualityOverview = Property(QObject, lambda self: self.quality_overview, constant=True) + scenariosModel = Property(QObject, lambda self: self.scenarios, constant=True) + partitionsModel = Property(QObject, lambda self: self.partitions, constant=True) + leakageAudits = Property(QObject, lambda self: self.leakage_audits, constant=True) + duplicateStateChecks = Property( + QObject, + lambda self: self.duplicate_state_checks, + constant=True, + ) + gridGroups = Property(QObject, lambda self: self.grid_groups, constant=True) + gridSpacing = Property(QObject, lambda self: self.grid_spacing, constant=True) + gridPhaseCounts = Property(QObject, lambda self: self.grid_phase_counts, constant=True) + matrixChecks = Property(QObject, lambda self: self.matrix_checks, constant=True) + matrixFeatureFlags = Property( + QObject, + lambda self: self.matrix_feature_flags, + constant=True, + ) + singularValues = Property(QObject, lambda self: self.singular_values, constant=True) + correlatedFeaturePairs = Property( + QObject, + lambda self: self.correlated_feature_pairs, + constant=True, + ) + featureTargetCorrelations = Property( + QObject, + lambda self: self.feature_target_correlations, + constant=True, + ) + baselineChecks = Property(QObject, lambda self: self.baseline_checks, constant=True) + baselineMetrics = Property(QObject, lambda self: self.baseline_metrics, constant=True) + baselineFailures = Property(QObject, lambda self: self.baseline_failures, constant=True) diff --git a/src/carnopy/app/preparation_draft.py b/src/carnopy/app/preparation_draft.py new file mode 100644 index 0000000..23dca1b --- /dev/null +++ b/src/carnopy/app/preparation_draft.py @@ -0,0 +1,1457 @@ +from __future__ import annotations + +import copy +import math +from collections.abc import Mapping +from typing import Any + +from pydantic import ValidationError +from PySide6.QtCore import Property, QObject, Signal, Slot + +from carnopy.app.draft_models import DraftItem, DraftListModel +from carnopy.app.field_ids import ( + PREPARATION_AUXILIARY, + PREPARATION_BASELINE_DIAGNOSTICS, + PREPARATION_CATEGORICAL_FEATURES, + PREPARATION_FEATURES, + PREPARATION_MATRIX_DIAGNOSTICS, + PREPARATION_OUTPUTS, + PREPARATION_SCENARIO_ACTIVE, + PREPARATION_SOURCE_POLICY, + PREPARATION_TARGETS, +) +from carnopy.app.scenario_draft import ScenarioDraft +from carnopy.app.workflow_models import WorkflowListModel + +DERIVED_FEATURES = ( + "specific_volume", + "reduced_temperature", + "reduced_pressure", + "compressibility_factor", +) +CATEGORICAL_FIELDS = ("phase", "fluid") +ARRAY_FORMATS = ("npy", "npz", "safetensors") +ARRAY_DTYPES = ("float32", "float64") +BASELINE_MODELS = ("dummy_mean", "ridge", "hist_gradient_boosting") + + +class PreparationDraft(QObject): + """Compose source-independent Preparation roles for the global document.""" + + changed = Signal() + validity_changed = Signal() + dirty_changed = Signal() + profile_changed = Signal() + capability_changed = Signal() + active_scenario_draft_changed = Signal() + message = Signal(str) + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self.numeric_choices = DraftListModel(self, disable_incompatible=True) + self.derived_choices = DraftListModel(self, disable_incompatible=True) + self.target_choices = DraftListModel(self, disable_incompatible=True) + self.auxiliary_choices = DraftListModel(self, disable_incompatible=True) + self.categorical_choices = DraftListModel(self, disable_incompatible=True) + self.array_format_choices = DraftListModel(self, disable_incompatible=True) + self.baseline_model_choices = DraftListModel(self, disable_incompatible=True) + self.scenarios_model = WorkflowListModel(("name", "kind", "summary"), self) + self._profile: dict[str, Any] = {} + self._capabilities: dict[str, Any] = {} + self._preserved: dict[str, Any] | None = None + self._numeric: tuple[str, ...] = () + self._derived: tuple[str, ...] = () + self._categorical: dict[str, str | tuple[str, ...]] = {} + self._targets: tuple[str, ...] = () + self._auxiliary: tuple[str, ...] = () + self._known_numeric: tuple[str, ...] = () + self._known_auxiliary: tuple[str, ...] = () + self._allow_partial_sweep = False + self._array_formats: tuple[str, ...] = () + self._array_dtype = "float32" + self._include_auxiliary = False + self._matrix_enabled = False + self._correlation_threshold = "0.995" + self._near_constant_spread = "1e-12" + self._baseline_enabled = False + self._baseline_models: tuple[str, ...] = ("dummy_mean", "ridge") + self._baseline_seed = "42" + self._ridge_alpha = "1.0" + self._histogram_iterations = "100" + self._scenarios: tuple[dict[str, Any], ...] = () + self._active_scenario: ScenarioDraft | None = None + self._active_scenario_row = -1 + self._safetensors_available = False + self._safetensors_guidance = ( + 'Install the optional dependency with: pip install "carnopy[ml]"' + ) + self._analysis_available = False + self._analysis_guidance = ( + 'Install the optional dependency with: pip install "carnopy[analysis]"' + ) + self._baseline: dict[str, Any] | None = None + self._baseline_raw: tuple[object, ...] | None = None + self._loaded = False + self._loading = False + self._refresh_models() + + def get_numeric_choices(self) -> QObject: + return self.numeric_choices + + numericChoices = Property(QObject, get_numeric_choices, constant=True) + + def get_derived_choices(self) -> QObject: + return self.derived_choices + + derivedChoices = Property(QObject, get_derived_choices, constant=True) + + def get_target_choices(self) -> QObject: + return self.target_choices + + targetChoices = Property(QObject, get_target_choices, constant=True) + + def get_auxiliary_choices(self) -> QObject: + return self.auxiliary_choices + + auxiliaryChoices = Property(QObject, get_auxiliary_choices, constant=True) + + def get_categorical_choices(self) -> QObject: + return self.categorical_choices + + categoricalChoices = Property(QObject, get_categorical_choices, constant=True) + + def get_array_format_choices(self) -> QObject: + return self.array_format_choices + + arrayFormatChoices = Property(QObject, get_array_format_choices, constant=True) + + def get_baseline_model_choices(self) -> QObject: + return self.baseline_model_choices + + baselineModelChoices = Property(QObject, get_baseline_model_choices, constant=True) + + def get_scenarios_model(self) -> QObject: + return self.scenarios_model + + scenarios = Property(QObject, get_scenarios_model, constant=True) + + def get_active_scenario_draft(self) -> QObject | None: + return self._active_scenario + + activeScenarioDraft = Property( + QObject, + get_active_scenario_draft, + notify=active_scenario_draft_changed, + ) + + def get_has_active_scenario_edit(self) -> bool: + return self._active_scenario is not None + + hasActiveScenarioEdit = Property( + bool, + get_has_active_scenario_edit, + notify=active_scenario_draft_changed, + ) + + def get_active_scenario_row(self) -> int: + return self._active_scenario_row + + activeScenarioRow = Property( + int, + get_active_scenario_row, + notify=active_scenario_draft_changed, + ) + + def get_allow_partial_sweep(self) -> bool: + return self._allow_partial_sweep + + @Slot(bool, result=bool) + def set_allow_partial_sweep(self, value: bool) -> bool: + selected = bool(value) + if selected == self._allow_partial_sweep: + return False + self._allow_partial_sweep = selected + self._state_changed() + return True + + def _set_allow_partial_sweep_property(self, value: bool) -> None: + self.set_allow_partial_sweep(value) + + allowPartialSweep = Property( + bool, + get_allow_partial_sweep, + _set_allow_partial_sweep_property, + notify=changed, + ) + + def get_array_outputs_enabled(self) -> bool: + return bool(self._array_formats) + + @Slot(bool, result=bool) + def set_array_outputs_enabled(self, value: bool) -> bool: + enabled = bool(value) + if enabled == bool(self._array_formats): + return False + self._array_formats = ("npz",) if enabled else () + self._state_changed() + return True + + def _set_array_outputs_enabled_property(self, value: bool) -> None: + self.set_array_outputs_enabled(value) + + arrayOutputsEnabled = Property( + bool, + get_array_outputs_enabled, + _set_array_outputs_enabled_property, + notify=changed, + ) + + def get_array_dtype(self) -> str: + return self._array_dtype + + @Slot(str, result=bool) + def set_array_dtype(self, value: str) -> bool: + if value not in ARRAY_DTYPES or value == self._array_dtype: + return False + self._array_dtype = value + self._state_changed() + return True + + def _set_array_dtype_property(self, value: str) -> None: + self.set_array_dtype(value) + + arrayDtype = Property(str, get_array_dtype, _set_array_dtype_property, notify=changed) + + def get_include_auxiliary(self) -> bool: + return self._include_auxiliary + + @Slot(bool, result=bool) + def set_include_auxiliary(self, value: bool) -> bool: + selected = bool(value) + if selected == self._include_auxiliary: + return False + self._include_auxiliary = selected + self._state_changed() + return True + + def _set_include_auxiliary_property(self, value: bool) -> None: + self.set_include_auxiliary(value) + + includeAuxiliary = Property( + bool, + get_include_auxiliary, + _set_include_auxiliary_property, + notify=changed, + ) + + def get_matrix_enabled(self) -> bool: + return self._matrix_enabled + + @Slot(bool, result=bool) + def set_matrix_enabled(self, value: bool) -> bool: + selected = bool(value) + if selected == self._matrix_enabled: + return False + self._matrix_enabled = selected + self._state_changed() + return True + + def _set_matrix_enabled_property(self, value: bool) -> None: + self.set_matrix_enabled(value) + + matrixDiagnosticsEnabled = Property( + bool, + get_matrix_enabled, + _set_matrix_enabled_property, + notify=changed, + ) + + def get_correlation_threshold(self) -> str: + return self._correlation_threshold + + @Slot(str, result=bool) + def set_correlation_threshold(self, value: str) -> bool: + return self._set_text("_correlation_threshold", value) + + def _set_correlation_threshold_property(self, value: str) -> None: + self.set_correlation_threshold(value) + + correlationThreshold = Property( + str, + get_correlation_threshold, + _set_correlation_threshold_property, + notify=changed, + ) + + def get_near_constant_spread(self) -> str: + return self._near_constant_spread + + @Slot(str, result=bool) + def set_near_constant_spread(self, value: str) -> bool: + return self._set_text("_near_constant_spread", value) + + def _set_near_constant_spread_property(self, value: str) -> None: + self.set_near_constant_spread(value) + + nearConstantRelativeSpread = Property( + str, + get_near_constant_spread, + _set_near_constant_spread_property, + notify=changed, + ) + + def get_baseline_enabled(self) -> bool: + return self._baseline_enabled + + @Slot(bool, result=bool) + def set_baseline_enabled(self, value: bool) -> bool: + selected = bool(value) + if selected == self._baseline_enabled: + return False + if selected and not self._analysis_available: + self.message.emit(self._analysis_guidance) + return False + self._baseline_enabled = selected + self._state_changed() + return True + + def _set_baseline_enabled_property(self, value: bool) -> None: + self.set_baseline_enabled(value) + + baselineDiagnosticsEnabled = Property( + bool, + get_baseline_enabled, + _set_baseline_enabled_property, + notify=changed, + ) + + def get_baseline_seed(self) -> str: + return self._baseline_seed + + @Slot(str, result=bool) + def set_baseline_seed(self, value: str) -> bool: + return self._set_text("_baseline_seed", value) + + def _set_baseline_seed_property(self, value: str) -> None: + self.set_baseline_seed(value) + + baselineRandomSeed = Property( + str, + get_baseline_seed, + _set_baseline_seed_property, + notify=changed, + ) + + def get_ridge_alpha(self) -> str: + return self._ridge_alpha + + @Slot(str, result=bool) + def set_ridge_alpha(self, value: str) -> bool: + return self._set_text("_ridge_alpha", value) + + def _set_ridge_alpha_property(self, value: str) -> None: + self.set_ridge_alpha(value) + + ridgeAlpha = Property(str, get_ridge_alpha, _set_ridge_alpha_property, notify=changed) + + def get_histogram_iterations(self) -> str: + return self._histogram_iterations + + @Slot(str, result=bool) + def set_histogram_iterations(self, value: str) -> bool: + return self._set_text("_histogram_iterations", value) + + def _set_histogram_iterations_property(self, value: str) -> None: + self.set_histogram_iterations(value) + + histogramMaxIterations = Property( + str, + get_histogram_iterations, + _set_histogram_iterations_property, + notify=changed, + ) + + def get_safetensors_available(self) -> bool: + return self._safetensors_available + + safetensorsAvailable = Property(bool, get_safetensors_available, notify=capability_changed) + + def get_baseline_available(self) -> bool: + return self._analysis_available + + baselineDiagnosticsAvailable = Property( + bool, + get_baseline_available, + notify=capability_changed, + ) + + def get_baseline_guidance(self) -> str: + return "" if self._analysis_available else self._analysis_guidance + + baselineDiagnosticsGuidance = Property( + str, + get_baseline_guidance, + notify=capability_changed, + ) + + def get_dependency_issue(self) -> str: + if "safetensors" in self._array_formats and not self._safetensors_available: + return self._safetensors_guidance + if self._baseline_enabled and not self._analysis_available: + return self._analysis_guidance + return "" + + dependencyIssue = Property(str, get_dependency_issue, notify=capability_changed) + + def get_source_kind(self) -> str: + value = self._profile.get("source_kind") + return value if isinstance(value, str) else "" + + sourceKind = Property(str, get_source_kind, notify=profile_changed) + + def get_profile_available(self) -> bool: + return bool(self._profile) + + profileAvailable = Property(bool, get_profile_available, notify=profile_changed) + + def get_locally_valid(self) -> bool: + return not self.get_issue() + + locallyValid = Property(bool, get_locally_valid, notify=validity_changed) + + def get_issue(self) -> str: + if not self._loaded: + return "No ML Preparation configuration is open." + try: + self.payload() + except ValueError as exc: + return str(exc) + return "" + + issue = Property(str, get_issue, notify=validity_changed) + + def get_source_issue(self) -> str: + if not self._profile: + return "" + completion = self._profile.get("completion") + if ( + self.get_source_kind() == "model_sweep" + and isinstance(completion, Mapping) + and bool(completion.get("partial", False)) + and not self._allow_partial_sweep + ): + return ( + "The bound Model Sweep is partial. Enable the explicit partial-sweep source " + "policy before planning." + ) + checks = ( + ("numeric feature", self._numeric, self._candidate_names("numeric_candidates")), + ("target", self._targets, self._candidate_names("target_candidates")), + ("auxiliary field", self._auxiliary, self._candidate_names("auxiliary_candidates")), + ( + "categorical feature", + tuple(self._categorical), + self._candidate_names("categorical_candidates"), + ), + ) + for label, selected, available in checks: + missing = [value for value in selected if value not in available] + if missing: + return ( + f"Selected {label}s are unavailable in the bound source: {', '.join(missing)}." + ) + derived = self._derived_status() + unavailable_derived = [ + value for value in self._derived if not bool(derived.get(value, {}).get("available")) + ] + if unavailable_derived: + return ( + "Selected derived features are unavailable in the bound source: " + + ", ".join(unavailable_derived) + + "." + ) + if issue := self._committed_scenario_source_issue(): + return issue + reference_context = self._profile.get("reference_context") + if isinstance(reference_context, Mapping) and not bool( + reference_context.get("compatible", False) + ): + selected = (*self._numeric, *self._targets) + reference_dependent = self._reference_dependent_fields() + affected = [value for value in selected if value in reference_dependent] + if affected: + reason = str(reference_context.get("reason", "")).strip() + return reason or ( + "The bound source has incompatible reference contexts for: " + + ", ".join(affected) + + "." + ) + return "" + + sourceIssue = Property(str, get_source_issue, notify=profile_changed) + + def get_model_holdout_available(self) -> bool: + model_holdout = self._profile.get("model_holdout") + return isinstance(model_holdout, Mapping) and bool(model_holdout.get("available", False)) + + modelHoldoutAvailable = Property( + bool, + get_model_holdout_available, + notify=profile_changed, + ) + + def get_model_holdout_issue(self) -> str: + if self.get_model_holdout_available(): + return "" + if not self._profile: + return "Bind an eligible Model Sweep source before adding a model holdout scenario." + model_holdout = self._profile.get("model_holdout") + if isinstance(model_holdout, Mapping): + reason = str(model_holdout.get("reason", "")).strip() + if reason: + return reason + return "Model holdout scenarios are unavailable for the bound source." + + modelHoldoutIssue = Property( + str, + get_model_holdout_issue, + notify=profile_changed, + ) + + def get_first_invalid_field(self) -> str: + if self._active_scenario is not None: + return self._active_scenario.get_first_invalid_field() or PREPARATION_SCENARIO_ACTIVE + issue = self.get_issue().casefold() + if not issue: + return "" + if "target" in issue: + return PREPARATION_TARGETS + if "auxiliary" in issue: + return PREPARATION_AUXILIARY + if "categor" in issue: + return PREPARATION_CATEGORICAL_FEATURES + if "partial" in issue or "source policy" in issue: + return PREPARATION_SOURCE_POLICY + if "baseline" in issue or "ridge" in issue or "histogram" in issue: + return PREPARATION_BASELINE_DIAGNOSTICS + if "matrix" in issue or "correlation" in issue or "spread" in issue: + return PREPARATION_MATRIX_DIAGNOSTICS + if "array" in issue or "parquet" in issue or "output" in issue or "dtype" in issue: + return PREPARATION_OUTPUTS + return PREPARATION_FEATURES + + firstInvalidField = Property(str, get_first_invalid_field, notify=validity_changed) + + def get_first_invalid_row(self) -> int: + if self._active_scenario is not None: + nested_row = self._active_scenario.get_first_invalid_row() + return nested_row if nested_row >= 0 else self._active_scenario_row + return -1 + + firstInvalidRow = Property(int, get_first_invalid_row, notify=validity_changed) + + def get_transient_edit_issue(self) -> str: + if self._active_scenario is None: + return "" + return self._active_scenario.get_issue() or ( + "Commit or cancel the active Preparation scenario edit." + ) + + def get_dirty(self) -> bool: + if self._baseline is None or self._baseline_raw is None: + return False + try: + return self.payload() != self._baseline + except ValueError: + return self.raw_state() != self._baseline_raw + + dirty = Property(bool, get_dirty, notify=dirty_changed) + + def apply_capabilities(self, payload: Mapping[str, object]) -> bool: + updated = copy.deepcopy(dict(payload)) + workflows = payload.get("workflows") + preparation = workflows.get("preparation") if isinstance(workflows, Mapping) else None + safetensors_available = False + safetensors_guidance = self._safetensors_guidance + analysis_available = False + analysis_guidance = self._analysis_guidance + if isinstance(preparation, Mapping): + safetensors = preparation.get("safetensors") + if isinstance(safetensors, Mapping): + safetensors_available = bool(safetensors.get("available", False)) + safetensors_guidance = str(safetensors.get("guidance", safetensors_guidance)) + baseline = preparation.get("baseline_diagnostics") + if isinstance(baseline, Mapping): + analysis_available = bool(baseline.get("available", False)) + analysis_guidance = str(baseline.get("guidance", analysis_guidance)) + semantic = ( + updated, + safetensors_available, + safetensors_guidance, + analysis_available, + analysis_guidance, + ) + current = ( + self._capabilities, + self._safetensors_available, + self._safetensors_guidance, + self._analysis_available, + self._analysis_guidance, + ) + if semantic == current: + return False + self._capabilities = updated + self._safetensors_available = safetensors_available + self._safetensors_guidance = safetensors_guidance + self._analysis_available = analysis_available + self._analysis_guidance = analysis_guidance + self._refresh_models() + self.capability_changed.emit() + return True + + def apply_source_profile(self, profile: Mapping[str, object] | None) -> bool: + updated = copy.deepcopy(dict(profile)) if profile is not None else {} + if updated == self._profile: + return False + self._profile = updated + self._refresh_models() + self.profile_changed.emit() + return True + + def load_payload(self, payload: Mapping[str, object]) -> None: + from carnopy.preparation.models import PreparationConfig + + validated = PreparationConfig.model_validate(payload) + value = validated.model_dump(mode="json", exclude_none=True) + source_policy = _mapping(value.get("source_policy")) + features = _mapping(value.get("features")) + categorical = value.get("categorical_features") + quality = _mapping(value.get("quality")) + outputs = _mapping(value.get("outputs")) + scenarios = value.get("scenarios") + had_active_scenario = self._active_scenario is not None + self._loading = True + try: + self._discard_active_scenario() + self._preserved = copy.deepcopy(value) + self._allow_partial_sweep = bool(source_policy.get("allow_partial_sweep", False)) + self._numeric = _strings(features.get("numeric")) + self._derived = _strings(features.get("derived")) + self._targets = _strings(value.get("targets")) + self._auxiliary = _strings(value.get("auxiliary")) + self._known_numeric = tuple(dict.fromkeys((*self._numeric, *self._targets))) + self._known_auxiliary = self._auxiliary + self._categorical = {} + if isinstance(categorical, list): + for raw_item in categorical: + if not isinstance(raw_item, Mapping): + continue + field = str(raw_item.get("field", "")) + raw_categories = raw_item.get("categories", "observed") + categories: str | tuple[str, ...] = ( + _strings(raw_categories) + if isinstance(raw_categories, list | tuple) + else "observed" + ) + self._categorical[field] = categories + arrays = outputs.get("arrays") + if isinstance(arrays, Mapping): + self._array_formats = _strings(arrays.get("formats")) + self._array_dtype = str(arrays.get("dtype", "float32")) + self._include_auxiliary = bool(arrays.get("include_auxiliary", False)) + else: + self._array_formats = () + self._array_dtype = "float32" + self._include_auxiliary = False + matrix = quality.get("matrix_diagnostics") + self._matrix_enabled = isinstance(matrix, Mapping) + self._correlation_threshold = _number_text( + matrix.get("correlation_threshold", 0.995) if isinstance(matrix, Mapping) else 0.995 + ) + self._near_constant_spread = _number_text( + matrix.get("near_constant_relative_spread", 1e-12) + if isinstance(matrix, Mapping) + else 1e-12 + ) + baseline = quality.get("baseline_diagnostics") + self._baseline_enabled = isinstance(baseline, Mapping) + if isinstance(baseline, Mapping): + self._baseline_models = _strings(baseline.get("models")) + self._baseline_seed = str(baseline.get("random_seed", 42)) + self._ridge_alpha = _number_text(baseline.get("ridge_alpha", 1.0)) + self._histogram_iterations = str(baseline.get("histogram_max_iterations", 100)) + else: + self._baseline_models = ("dummy_mean", "ridge") + self._baseline_seed = "42" + self._ridge_alpha = "1.0" + self._histogram_iterations = "100" + self._scenarios = ( + tuple(copy.deepcopy(dict(item)) for item in scenarios if isinstance(item, Mapping)) + if isinstance(scenarios, list) + else () + ) + self._loaded = True + self._refresh_models() + finally: + self._loading = False + self._baseline = copy.deepcopy(value) + self._baseline_raw = self.raw_state() + self.validity_changed.emit() + self.dirty_changed.emit() + self.capability_changed.emit() + self.changed.emit() + if had_active_scenario: + self.active_scenario_draft_changed.emit() + + def clear(self) -> None: + had_active_scenario = self._active_scenario is not None + self._loading = True + try: + self._discard_active_scenario() + self._preserved = None + self._numeric = () + self._derived = () + self._categorical = {} + self._targets = () + self._auxiliary = () + self._known_numeric = () + self._known_auxiliary = () + self._allow_partial_sweep = False + self._array_formats = () + self._array_dtype = "float32" + self._include_auxiliary = False + self._matrix_enabled = False + self._correlation_threshold = "0.995" + self._near_constant_spread = "1e-12" + self._baseline_enabled = False + self._baseline_models = ("dummy_mean", "ridge") + self._baseline_seed = "42" + self._ridge_alpha = "1.0" + self._histogram_iterations = "100" + self._scenarios = () + self._baseline = None + self._baseline_raw = None + self._loaded = False + self._refresh_models() + finally: + self._loading = False + self.validity_changed.emit() + self.dirty_changed.emit() + self.capability_changed.emit() + self.changed.emit() + if had_active_scenario: + self.active_scenario_draft_changed.emit() + + def mark_baseline(self) -> None: + if issue := self.get_issue(): + raise ValueError(f"cannot mark an invalid ML Preparation draft as saved: {issue}") + self._baseline = self.payload() + self._baseline_raw = self.raw_state() + self.dirty_changed.emit() + + @Slot(result=bool) + def begin_add_scenario(self) -> bool: + if not self._loaded or self._active_scenario is not None: + return False + self._active_scenario_row = -1 + self._active_scenario = ScenarioDraft( + field_choices=self._scenario_field_choices(), + parent=self, + ) + self._active_scenario.validity_changed.connect(self.validity_changed.emit) + self.active_scenario_draft_changed.emit() + self.validity_changed.emit() + return True + + @Slot(int, result=bool) + def begin_edit_scenario(self, row: int) -> bool: + if self._active_scenario is not None or not 0 <= row < len(self._scenarios): + return False + self._active_scenario_row = row + self._active_scenario = ScenarioDraft( + field_choices=self._scenario_field_choices(), + payload=self._scenarios[row], + parent=self, + ) + self._active_scenario.validity_changed.connect(self.validity_changed.emit) + self.active_scenario_draft_changed.emit() + self.validity_changed.emit() + return True + + @Slot(result=bool) + def commit_scenario(self) -> bool: + draft = self._active_scenario + if draft is None: + return False + try: + value = draft.detached_payload() + except ValueError as exc: + self.message.emit(str(exc)) + return False + names = [str(item.get("name", "")) for item in self._scenarios] + if value["name"] in names and ( + self._active_scenario_row < 0 or names[self._active_scenario_row] != value["name"] + ): + self.message.emit("Preparation scenario names must be unique.") + return False + updated = list(self._scenarios) + if self._active_scenario_row < 0: + updated.append(value) + else: + updated[self._active_scenario_row] = value + changed = tuple(updated) != self._scenarios + if not changed: + self._discard_active_scenario() + self.active_scenario_draft_changed.emit() + self.validity_changed.emit() + return True + if issue := self._scenario_value_source_issue(value): + self.message.emit(issue) + return False + try: + self._validated_payload(tuple(updated)) + except ValueError as exc: + self.message.emit(str(exc)) + return False + self._scenarios = tuple(updated) + self._discard_active_scenario() + self.active_scenario_draft_changed.emit() + self._state_changed() + return True + + @Slot(result=bool) + def cancel_scenario(self) -> bool: + if self._active_scenario is None: + return False + self._discard_active_scenario() + self.active_scenario_draft_changed.emit() + self.validity_changed.emit() + return True + + @Slot(int, result=bool) + def remove_scenario(self, row: int) -> bool: + if self._active_scenario is not None or not 0 <= row < len(self._scenarios): + return False + self._scenarios = (*self._scenarios[:row], *self._scenarios[row + 1 :]) + self._state_changed() + return True + + @Slot(int, int, result=bool) + def move_scenario(self, source: int, destination: int) -> bool: + values = list(self._scenarios) + if ( + self._active_scenario is not None + or not 0 <= source < len(values) + or not 0 <= destination < len(values) + or source == destination + ): + return False + item = values.pop(source) + values.insert(destination, item) + self._scenarios = tuple(values) + self._state_changed() + return True + + def payload(self) -> dict[str, Any]: + return self._validated_payload(self._scenarios) + + def _validated_payload( + self, + scenarios: tuple[dict[str, Any], ...], + ) -> dict[str, Any]: + from carnopy.preparation.models import PreparationConfig + + if not self._loaded or self._preserved is None: + raise ValueError("No ML Preparation configuration is open.") + result = copy.deepcopy(self._preserved) + result["source_policy"] = {"allow_partial_sweep": self._allow_partial_sweep} + result["features"] = { + "numeric": list(self._numeric), + "derived": list(self._derived), + } + result["categorical_features"] = [ + { + "field": field, + "encoding": "one_hot", + "categories": list(categories) if isinstance(categories, tuple) else categories, + } + for field, categories in self._categorical.items() + ] + result["targets"] = list(self._targets) + result["auxiliary"] = list(self._auxiliary) + result["scenarios"] = copy.deepcopy(list(scenarios)) + quality: dict[str, Any] = {} + if self._matrix_enabled: + quality["matrix_diagnostics"] = { + "correlation_threshold": _bounded_float( + self._correlation_threshold, + "correlation threshold", + maximum=1.0, + ), + "near_constant_relative_spread": _positive_float( + self._near_constant_spread, + "near-constant relative spread", + ), + } + if self._baseline_enabled: + quality["baseline_diagnostics"] = { + "models": list(self._baseline_models), + "random_seed": _integer(self._baseline_seed, "baseline random seed"), + "ridge_alpha": _positive_float(self._ridge_alpha, "ridge alpha"), + "histogram_max_iterations": _positive_integer( + self._histogram_iterations, + "histogram maximum iterations", + ), + } + result["quality"] = quality + outputs: dict[str, Any] = {"formats": ["parquet"], "parquet": True} + if self._array_formats: + outputs["arrays"] = { + "formats": list(self._array_formats), + "dtype": self._array_dtype, + "include_auxiliary": self._include_auxiliary, + } + result["outputs"] = outputs + try: + model = PreparationConfig.model_validate(result) + except ValidationError as exc: + raise ValueError(str(exc)) from exc + return model.model_dump(mode="json", exclude_none=True) + + def scenario_payloads(self) -> tuple[dict[str, Any], ...]: + return tuple(copy.deepcopy(item) for item in self._scenarios) + + def raw_state(self) -> tuple[object, ...]: + return ( + self._loaded, + self._allow_partial_sweep, + self._numeric, + self._derived, + tuple(self._categorical.items()), + self._targets, + self._auxiliary, + copy.deepcopy(self._scenarios), + self._array_formats, + self._array_dtype, + self._include_auxiliary, + self._matrix_enabled, + self._correlation_threshold, + self._near_constant_spread, + self._baseline_enabled, + self._baseline_models, + self._baseline_seed, + self._ridge_alpha, + self._histogram_iterations, + ) + + def selected_values(self, role: str) -> tuple[str, ...]: + attribute = _role_attribute(role) + return () if attribute is None else tuple(getattr(self, attribute)) + + @Slot(str, bool, result=bool) + def set_role_selected(self, role: str, value: str, selected: bool) -> bool: + attribute = _role_attribute(role) + model = self._role_model(role) + if attribute is None or model is None: + return False + current = list(getattr(self, attribute)) + if selected: + candidate = next((item for item in model.items if item.value == value), None) + if candidate is None or not candidate.compatible: + self.message.emit( + candidate.issue if candidate is not None else f"Unknown {role}: {value}." + ) + return False + if value in current: + return False + current.append(value) + else: + if value not in current: + return False + current.remove(value) + setattr(self, attribute, tuple(current)) + self._state_changed() + return True + + @Slot(str, bool, result=bool) + def set_categorical_selected(self, field: str, selected: bool) -> bool: + if field not in CATEGORICAL_FIELDS: + return False + if selected: + candidate = next( + (item for item in self.categorical_choices.items if item.value == field), + None, + ) + if candidate is None or not candidate.compatible: + self.message.emit( + candidate.issue if candidate is not None else f"Unknown category: {field}." + ) + return False + if field in self._categorical: + return False + self._categorical[field] = "observed" + else: + if field not in self._categorical: + return False + del self._categorical[field] + self._state_changed() + return True + + @Slot(str, str, bool, result=bool) + def set_category_mode( + self, + field: str, + mode: str, + discard_confirmed: bool = False, + ) -> bool: + current = self._categorical.get(field) + if current is None or mode not in {"observed", "explicit"}: + return False + if mode == self.category_mode(field): + return False + if mode == "observed" and current and not discard_confirmed: + self.message.emit( + "Confirm replacing the explicit category list with source-observed values." + ) + return False + self._categorical[field] = "observed" if mode == "observed" else () + self._state_changed() + return True + + @Slot(str, str, result=bool) + def set_explicit_categories(self, field: str, comma_values: str) -> bool: + current = self._categorical.get(field) + if not isinstance(current, tuple): + return False + raw_values = tuple(item.strip() for item in comma_values.split(",")) + if len(raw_values) == 1 and not raw_values[0]: + values: tuple[str, ...] = () + elif any(not item for item in raw_values): + self.message.emit("Explicit categories must not contain blank values.") + return False + else: + values = raw_values + if len(set(values)) != len(values): + self.message.emit("Explicit categories must be unique.") + return False + if values == current: + return False + self._categorical[field] = values + self._state_changed() + return True + + @Slot(str, result=str) + def category_mode(self, field: str) -> str: + return "explicit" if isinstance(self._categorical.get(field), tuple) else "observed" + + @Slot(str, result=str) + def explicit_categories_text(self, field: str) -> str: + values = self._categorical.get(field) + return ", ".join(values) if isinstance(values, tuple) else "" + + @Slot(str, result=list) + def observed_categories(self, field: str) -> list[str]: + observed = self._profile.get("observed_category_values") + values = observed.get(field) if isinstance(observed, Mapping) else None + return list(_strings(values)) + + @Slot(str, bool, result=bool) + def set_array_format_selected(self, value: str, selected: bool) -> bool: + if value not in ARRAY_FORMATS: + return False + values = list(self._array_formats) + if selected: + candidate = next( + (item for item in self.array_format_choices.items if item.value == value), + None, + ) + if candidate is None or not candidate.compatible: + self.message.emit( + candidate.issue if candidate is not None else f"Unknown array format: {value}." + ) + return False + if value in values: + return False + values.append(value) + else: + if value not in values: + return False + values.remove(value) + self._array_formats = tuple(item for item in ARRAY_FORMATS if item in values) + self._state_changed() + return True + + @Slot(str, bool, result=bool) + def set_baseline_model_selected(self, value: str, selected: bool) -> bool: + if value not in BASELINE_MODELS: + return False + values = list(self._baseline_models) + if selected: + candidate = next( + (item for item in self.baseline_model_choices.items if item.value == value), + None, + ) + if candidate is None or not candidate.compatible: + self.message.emit( + candidate.issue + if candidate is not None + else f"Unknown baseline model: {value}." + ) + return False + if value in values: + return False + values.append(value) + else: + if value not in values: + return False + values.remove(value) + self._baseline_models = tuple(item for item in BASELINE_MODELS if item in values) + self._state_changed() + return True + + def _set_text(self, attribute: str, value: str) -> bool: + updated = value.strip() + if updated == getattr(self, attribute): + return False + setattr(self, attribute, updated) + self._state_changed() + return True + + def _state_changed(self) -> None: + if self._loading: + return + self._refresh_models() + self.validity_changed.emit() + self.dirty_changed.emit() + self.profile_changed.emit() + self.capability_changed.emit() + self.changed.emit() + + def _discard_active_scenario(self) -> None: + if self._active_scenario is not None: + self._active_scenario.deleteLater() + self._active_scenario = None + self._active_scenario_row = -1 + + def _scenario_field_choices(self) -> tuple[str, ...]: + return tuple( + dict.fromkeys( + ( + *self._candidate_names("numeric_candidates"), + *self._candidate_names("target_candidates"), + *self._candidate_names("auxiliary_candidates"), + *self._candidate_names("categorical_candidates"), + *self._numeric, + *self._derived, + *self._targets, + *self._auxiliary, + *self._categorical, + "phase", + "fluid", + "backend_model", + ) + ) + ) + + def _committed_scenario_source_issue(self) -> str: + for scenario in self._scenarios: + if issue := self._scenario_value_source_issue(scenario): + return issue + return "" + + def _scenario_value_source_issue(self, scenario: Mapping[str, object]) -> str: + kind = str(scenario.get("kind", "")) + name = str(scenario.get("name", "scenario")) + if kind == "model_holdout" and not self.get_model_holdout_available(): + return self.get_model_holdout_issue() + if not self._profile: + return "" + category_field = { + "leave_fluid_out": "fluid", + "phase_holdout": "phase", + "model_holdout": "backend_model", + }.get(kind) + if category_field is None: + return "" + if category_field == "backend_model": + available = _strings(self._profile.get("available_models")) + else: + observed = self._profile.get("observed_category_values") + available = ( + _strings(observed.get(category_field)) if isinstance(observed, Mapping) else () + ) + holdouts = scenario.get("holdouts") + selected = ( + tuple( + str(item) + for values in holdouts.values() + if isinstance(values, list | tuple) + for item in values + ) + if isinstance(holdouts, Mapping) + else () + ) + unavailable = tuple(value for value in selected if value not in available) + if not unavailable: + return "" + return ( + f"Scenario {name!r} selects unavailable {_display(category_field).lower()} " + f"holdout values: {', '.join(unavailable)}." + ) + + def _role_model(self, role: str) -> DraftListModel | None: + return { + "numeric": self.numeric_choices, + "derived": self.derived_choices, + "target": self.target_choices, + "auxiliary": self.auxiliary_choices, + }.get(role) + + def _candidate_names(self, key: str) -> tuple[str, ...]: + return tuple(item["name"] for item in self._candidate_profiles(key)) + + def _candidate_profiles(self, key: str) -> tuple[dict[str, Any], ...]: + raw = self._profile.get(key) + if not isinstance(raw, list): + return () + return tuple(copy.deepcopy(item) for item in raw if isinstance(item, dict)) + + def _derived_status(self) -> dict[str, dict[str, Any]]: + raw = self._profile.get("derived_features") + if not isinstance(raw, list): + return {} + return { + str(item.get("name")): copy.deepcopy(item) for item in raw if isinstance(item, dict) + } + + def _reference_dependent_fields(self) -> set[str]: + result: set[str] = set() + for key in ("numeric_candidates", "target_candidates"): + result.update( + str(item.get("name")) + for item in self._candidate_profiles(key) + if bool(item.get("reference_dependent", False)) + ) + return result + + def _refresh_models(self) -> None: + self.numeric_choices.replace(self._role_items("numeric")) + self.derived_choices.replace(self._derived_items()) + self.target_choices.replace(self._role_items("target")) + self.auxiliary_choices.replace(self._role_items("auxiliary")) + categorical_available = self._candidate_names("categorical_candidates") + visible_categorical = tuple(dict.fromkeys((*CATEGORICAL_FIELDS, *self._categorical))) + self.categorical_choices.replace( + DraftItem( + value=value, + display=_display(value), + canonical=value, + compatible=(not self._profile or value in categorical_available), + selected=value in self._categorical, + issue=( + "Unavailable in the bound source." + if self._profile and value not in categorical_available + else "" + ), + ) + for value in visible_categorical + ) + self.array_format_choices.replace( + DraftItem( + value=value, + display=value.upper(), + canonical=value, + compatible=value != "safetensors" or self._safetensors_available, + selected=value in self._array_formats, + issue=( + self._safetensors_guidance + if value == "safetensors" and not self._safetensors_available + else "" + ), + ) + for value in ARRAY_FORMATS + ) + self.baseline_model_choices.replace( + DraftItem( + value=value, + display=_display(value), + canonical=value, + compatible=self._analysis_available, + selected=value in self._baseline_models, + issue="" if self._analysis_available else self._analysis_guidance, + ) + for value in BASELINE_MODELS + ) + self.scenarios_model.replace( + { + "name": str(item.get("name", "")), + "kind": str(item.get("kind", "")), + "summary": _scenario_summary(item), + } + for item in self._scenarios + ) + if self._active_scenario is not None: + self._active_scenario.set_field_choices(self._scenario_field_choices()) + + def _role_items(self, role: str) -> tuple[DraftItem, ...]: + key = { + "numeric": "numeric_candidates", + "target": "target_candidates", + "auxiliary": "auxiliary_candidates", + }[role] + selected = self.selected_values(role) + profiles = self._candidate_profiles(key) + by_name = {str(item.get("name")): item for item in profiles} + known = self._known_auxiliary if role == "auxiliary" else self._known_numeric + visible = tuple(dict.fromkeys((*by_name, *known, *selected))) + return tuple( + self._role_item(role, value, by_name.get(value), selected=value in selected) + for value in visible + ) + + def _role_item( + self, + role: str, + value: str, + profile: Mapping[str, object] | None, + *, + selected: bool, + ) -> DraftItem: + issue = self._role_choice_issue(role, value, profile) + return DraftItem( + value=value, + display=_display(value), + canonical=value, + compatible=not issue, + selected=selected, + issue=issue, + label=str(profile.get("column", "")) if profile is not None else "", + unit=str(profile.get("unit") or "") if profile is not None else "", + ) + + def _role_choice_issue( + self, + role: str, + value: str, + profile: Mapping[str, object] | None, + ) -> str: + if self._profile and profile is None: + return "Unavailable in the bound source." + conflicts = { + "numeric": (*self._derived, *self._targets, *self._auxiliary), + "target": (*self._numeric, *self._derived, *self._auxiliary), + "auxiliary": (*self._numeric, *self._derived, *self._targets), + }[role] + if value in conflicts: + return "Already selected for an incompatible Preparation role." + return "" + + def _derived_items(self) -> tuple[DraftItem, ...]: + status = self._derived_status() + visible = tuple(dict.fromkeys((*DERIVED_FEATURES, *self._derived))) + return tuple( + DraftItem( + value=value, + display=_display(value), + canonical=value, + compatible=( + value not in (*self._targets, *self._auxiliary) + and (not self._profile or bool(status.get(value, {}).get("available", False))) + ), + selected=value in self._derived, + issue=( + "Already selected for an incompatible Preparation role." + if value in (*self._targets, *self._auxiliary) + else str(status.get(value, {}).get("reason", "")) + or ("Unavailable in the bound source." if self._profile else "") + ), + unit=str(status.get(value, {}).get("unit") or ""), + ) + for value in visible + ) + + +def _role_attribute(role: str) -> str | None: + return { + "numeric": "_numeric", + "derived": "_derived", + "target": "_targets", + "auxiliary": "_auxiliary", + }.get(role) + + +def _mapping(value: object) -> dict[str, Any]: + return copy.deepcopy(value) if isinstance(value, dict) else {} + + +def _strings(value: object) -> tuple[str, ...]: + return tuple(str(item) for item in value) if isinstance(value, list | tuple) else () + + +def _number_text(value: object) -> str: + if isinstance(value, bool): + return str(value) + return format(float(value), ".12g") if isinstance(value, int | float) else str(value) + + +def _positive_float(value: str, label: str) -> float: + try: + result = float(value) + except ValueError as exc: + raise ValueError(f"{label} must be numeric") from exc + if not math.isfinite(result) or result <= 0.0: + raise ValueError(f"{label} must be finite and greater than zero") + return result + + +def _bounded_float(value: str, label: str, *, maximum: float) -> float: + result = _positive_float(value, label) + if result > maximum: + raise ValueError(f"{label} must be at most {maximum:g}") + return result + + +def _integer(value: str, label: str) -> int: + try: + return int(value) + except ValueError as exc: + raise ValueError(f"{label} must be an integer") from exc + + +def _positive_integer(value: str, label: str) -> int: + result = _integer(value, label) + if result < 1: + raise ValueError(f"{label} must be at least one") + return result + + +def _display(value: str) -> str: + return value.replace("_", " ").title() + + +def _scenario_summary(value: Mapping[str, object]) -> str: + kind = str(value.get("kind", "scenario")) + details: list[str] = [] + partitions = value.get("partitions") + if kind == "unsplit": + details.append("All partition") + elif isinstance(partitions, Mapping): + details.extend( + f"{_display(str(name))} {_percentage(ratio)}" for name, ratio in partitions.items() + ) + holdouts = value.get("holdouts") + if isinstance(holdouts, Mapping) and holdouts: + details.append("Holdouts " + ", ".join(_display(str(name)) for name in holdouts)) + if value.get("seed") is not None: + details.append(f"Seed {value['seed']}") + transformations = value.get("transformations") + if isinstance(transformations, list) and transformations: + count = len(transformations) + details.append(f"{count} transformation{'s' if count != 1 else ''}") + return " · ".join((_display(kind), *details)) + + +def _percentage(value: object) -> str: + if isinstance(value, int | float) and not isinstance(value, bool): + return f"{format(float(value) * 100.0, '.12g')}%" + return _number_text(value) diff --git a/src/carnopy/app/protocol.py b/src/carnopy/app/protocol.py index 03d6ed0..9b9ab14 100644 --- a/src/carnopy/app/protocol.py +++ b/src/carnopy/app/protocol.py @@ -9,6 +9,8 @@ RequestType = Literal[ "describe_capabilities", + "load_configuration", + "validate_configuration", "load_dataset_config", "validate_dataset_config", "validate_config", diff --git a/src/carnopy/app/qml/Carnopy/Main.qml b/src/carnopy/app/qml/Carnopy/Main.qml index 01820a5..990185b 100644 --- a/src/carnopy/app/qml/Carnopy/Main.qml +++ b/src/carnopy/app/qml/Carnopy/Main.qml @@ -28,7 +28,7 @@ ApplicationWindow { signal configurationAttentionRequested(string section, string field, int row) signal datasetCloseRequested(bool discardConfirmed) signal datasetConfirmReformatRequested(string action) - signal datasetImportRequested(string path, bool discardConfirmed) + signal configurationImportRequested(string path, bool discardConfirmed) signal datasetModelChangeRequested(string model) signal datasetModeChangeRequested(string mode) signal datasetCoordinateChangeRequested(string axis) @@ -46,6 +46,39 @@ ApplicationWindow { signal datasetSaveRequested(bool allowReformat) signal datasetValidateRequested signal datasetReloadRequested(bool discardConfirmed) + signal sweepNewRequested(bool discardConfirmed) + signal preparationNewRequested(bool discardConfirmed) + signal preparationArrayFormatSelectionRequested(string value, bool selected) + signal preparationBaselineModelSelectionRequested(string value, bool selected) + signal preparationBooleanFieldRequested(string field, bool value) + signal preparationCategoricalSelectionRequested(string field, bool selected) + signal preparationCategoryModeRequested(string field, string mode, bool confirmed) + signal preparationExplicitCategoriesRequested(string field, string values) + signal preparationRoleSelectionRequested(string role, string value, bool selected) + signal preparationScenarioAddRequested + signal preparationScenarioCancelRequested + signal preparationScenarioCommitRequested + signal preparationScenarioEditRequested(int row) + signal preparationScenarioMoveRequested(int source, int destination) + signal preparationScenarioRemoveRequested(int row) + signal preparationScenarioFieldChangeRequested(var draft, string field, string value) + signal preparationScenarioKindChangeRequested(var draft, string kind, bool confirmed) + signal preparationScenarioPartitionRequested(var draft, string partition, string ratio) + signal preparationScenarioRemovePartitionRequested(var draft, string partition) + signal preparationScenarioCategoricalHoldoutRequested(var draft, string partition, + string values) + signal preparationScenarioRangeHoldoutRequested(var draft, string partition, string minimum, + string maximum) + signal preparationScenarioCoordinateHoldoutRequested(var draft, string partition, string field, + string minimum, string maximum) + signal preparationScenarioRemoveHoldoutRequested(var draft, string partition) + signal preparationScenarioStrataRequested(var draft, string fields) + signal preparationScenarioNumericBinsRequested(var draft, string field, string boundaries) + signal preparationScenarioRemoveNumericBinsRequested(var draft, string field) + signal preparationScenarioTransformationAddRequested(var draft, string field, string methods) + signal preparationScenarioTransformationRemoveRequested(var draft, int row) + signal preparationScenarioTransformationMoveRequested(var draft, int source, int destination) + signal preparationTextFieldRequested(string field, string value) signal runCancelRequested signal runForceStopRequested signal runGenerateRequested @@ -59,6 +92,8 @@ ApplicationWindow { signal inspectionRefreshRequested signal inspectionSourcesRefreshRequested signal inspectionTableRequested(string tableId) + signal preparationSourceBindRequested + signal preparationSourceClearRequested(bool confirmed) signal activityInspectRunRequested signal activityRecordSelectionRequested(string recordId) signal activityRecordsRefreshRequested @@ -120,9 +155,15 @@ ApplicationWindow { / 312))) readonly property bool controllerAvailable: desktopController !== null readonly property var configController: controllerAvailable - ? desktopController.datasetConfigController : null + ? desktopController.configurationController : null readonly property var executionController: controllerAvailable ? desktopController.executionController : null + readonly property var sweepWorkflowController: controllerAvailable + ? desktopController.sweepWorkflowController : + null + readonly property var preparationWorkflowController: controllerAvailable + ? desktopController.preparationWorkflowController : + null readonly property var inspectionController: controllerAvailable ? desktopController.inspectionController : null readonly property var activityController: controllerAvailable @@ -160,6 +201,10 @@ ApplicationWindow { return qsTr("Help"); if (pageKey === "dataset") return qsTr("Dataset"); + if (pageKey === "sweeps") + return qsTr("Model Sweeps"); + if (pageKey === "preparation") + return qsTr("ML Preparation"); if (pageKey === "visualization") return qsTr("Visualization"); if (pageKey === "yaml") @@ -209,9 +254,9 @@ ApplicationWindow { root.datasetCloseRequested(false); } - function requestDatasetImport(path) { + function requestConfigurationImport(path) { if (controllerAvailable && desktopController.hasActivePlotEdit) { - root.datasetImportRequested(path, false); + root.configurationImportRequested(path, false); return; } if (configController !== null && configController.dirty) { @@ -220,7 +265,7 @@ ApplicationWindow { configurationDiscardDialog.open(); return; } - root.datasetImportRequested(path, false); + root.configurationImportRequested(path, false); } function requestDatasetNew(mode) { @@ -237,6 +282,106 @@ ApplicationWindow { root.datasetNewRequested(mode, false); } + function requestSweepNew() { + if (controllerAvailable && (desktopController.hasActivePlotEdit + || desktopController.hasActiveSweepEdit)) { + root.sweepNewRequested(false); + return; + } + if (configController !== null && configController.dirty) { + pendingReplacementAction = "new_sweep"; + configurationDiscardDialog.open(); + return; + } + root.sweepNewRequested(false); + } + + function requestPreparationNew() { + if (root.preparationWorkflowController !== null && + !root.preparationWorkflowController.hasBoundSource) { + root.preparationNewRequested(false); + return; + } + if (controllerAvailable && desktopController.hasAnyTransientEdit) { + root.preparationNewRequested(false); + return; + } + if (configController !== null && configController.dirty) { + pendingReplacementAction = "new_preparation"; + configurationDiscardDialog.open(); + return; + } + root.preparationNewRequested(false); + } + + function sweepStatusLabel() { + if (sweepWorkflowController === null) + return qsTr("Unavailable"); + if (controllerAvailable && desktopController.hasActiveSweepEdit) + return qsTr("Unfinished comparison edit"); + if (sweepWorkflowController.operationActive) + return sweepWorkflowController.protectedFinalization ? qsTr("Finalizing safely") : qsTr( + "Sweep active"); + if (configController === null || configController.documentKind !== "model_sweep") + return sweepWorkflowController.hasResult ? qsTr("Historical Sweep result") : qsTr( + "No Sweep document"); + if (sweepWorkflowController.planCurrent) + return qsTr("Plan current"); + return configController.dirty ? qsTr("Unsaved Sweep") : qsTr("Sweep ready"); + } + + function sweepStatusTone() { + if (sweepWorkflowController === null) + return "neutral"; + if (controllerAvailable && desktopController.hasActiveSweepEdit) + return "warning"; + if (sweepWorkflowController.workflowState === "failed" + || sweepWorkflowController.workflowState === "invalid") + return "danger"; + if (sweepWorkflowController.operationActive) + return sweepWorkflowController.protectedFinalization ? "warning" : "information"; + if (sweepWorkflowController.planCurrent || sweepWorkflowController.resultRelation + === "current") + return "success"; + if (sweepWorkflowController.hasPlan || sweepWorkflowController.hasResult) + return "warning"; + return "neutral"; + } + + function preparationStatusLabel() { + if (preparationWorkflowController === null) + return qsTr("Unavailable"); + if (controllerAvailable && desktopController.hasActivePreparationEdit) + return qsTr("Unfinished scenario edit"); + if (preparationWorkflowController.operationActive) + return preparationWorkflowController.protectedFinalization ? qsTr("Finalizing safely") : + qsTr("Preparation active"); + if (configController === null || configController.documentKind !== "preparation") + return preparationWorkflowController.hasResult ? qsTr("Historical Preparation result") : + qsTr("No Preparation document"); + if (preparationWorkflowController.planCurrent) + return qsTr("Plan current"); + return configController.dirty ? qsTr("Unsaved Preparation") : qsTr("Preparation ready"); + } + + function preparationStatusTone() { + if (preparationWorkflowController === null) + return "neutral"; + if (controllerAvailable && desktopController.hasActivePreparationEdit) + return "warning"; + if (preparationWorkflowController.workflowState === "failed" + || preparationWorkflowController.workflowState === "invalid") + return "danger"; + if (preparationWorkflowController.operationActive) + return preparationWorkflowController.protectedFinalization ? "warning" : "information"; + if (preparationWorkflowController.planCurrent + || preparationWorkflowController.resultRelation === "current") + return "success"; + if (preparationWorkflowController.hasPlan || preparationWorkflowController.hasResult) + return "warning"; + return "neutral"; + } + function requestCommandImport() { routeTo("workspace"); Qt.callLater(function () { @@ -440,6 +585,9 @@ ApplicationWindow { datasetAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable inspectAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable runAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable + sweepsAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable + preparationAvailable: root.controllerAvailable + && root.desktopController.workspaceAvailable visualizationAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable yamlAvailable: root.configController !== null && root.configController.hasDocument @@ -496,6 +644,10 @@ ApplicationWindow { showAppearanceSelector: !root.inspectorWideVisible showRailMenu: root.shellMode === "narrow" statusLabel: { + if (root.currentPage === "sweeps") + return root.sweepStatusLabel(); + if (root.currentPage === "preparation") + return root.preparationStatusLabel(); if (root.currentPage === "run" && root.executionController !== null) return root.executionController.state === "running" ? qsTr("Running") : ( root.executionController.state @@ -516,33 +668,41 @@ ApplicationWindow { return qsTr("Workspace ready"); return qsTr("No workspace"); } - statusTone: root.controllerAvailable && root.desktopController.workspaceState - === "loading" ? "information" : (root.currentPage === "run" - && root.executionController - !== null ? ( - root.executionController.state - === "succeeded" - ? "success" : ( - root.executionController.state - === "invalid" - || root.executionController.state - === "failed" - ? "danger" : ( + statusTone: root.currentPage === "sweeps" ? root.sweepStatusTone() : ( + root.currentPage + === "preparation" + ? root.preparationStatusTone() : + (root.controllerAvailable + && root.desktopController.workspaceState + === "loading" + ? "information" : ( + root.currentPage + === "run" + && root.executionController + !== null ? ( root.executionController.state - === "running" - || root.executionController.state - === "starting" - ? "information" : - "neutral"))) : - (root.controllerAvailable - && root.desktopController.workspaceState - === "editing" && - !root.desktopController.datasetDraft.locallyValid - ? "danger" : ( - root.controllerAvailable - && root.desktopController.workspaceAvailable - ? "success" : - "neutral"))) + === "succeeded" + ? "success" : + (root.executionController.state + === "invalid" + || root.executionController.state + === "failed" + ? "danger" : + (root.executionController.state + === "running" + || root.executionController.state + === "starting" + ? "information" : + "neutral"))) : + (root.controllerAvailable + && root.desktopController.workspaceState + === "editing" + && !root.desktopController.datasetDraft.locallyValid + ? "danger" : + (root.controllerAvailable + && root.desktopController.workspaceAvailable + ? "success" : + "neutral"))))) themeMode: root.qmlSettings.themeMode } @@ -643,6 +803,26 @@ ApplicationWindow { visible: root.currentPage === "dataset" } + Loader { + id: modelSweepPageLoader + + active: root.currentPage === "sweeps" || item !== null + anchors.fill: parent + objectName: "modelSweepPageLoader" + sourceComponent: modelSweepPage + visible: root.currentPage === "sweeps" + } + + Loader { + id: preparationPageLoader + + active: root.currentPage === "preparation" || item !== null + anchors.fill: parent + objectName: "preparationPageLoader" + sourceComponent: preparationPage + visible: root.currentPage === "preparation" + } + Loader { id: yamlPageLoader @@ -786,6 +966,7 @@ ApplicationWindow { Layout.fillHeight: true Layout.fillWidth: true closeButtonVisible: true + configController: root.configController configurationDirty: root.configController !== null && root.configController.dirty configurationFile: root.configController !== null ? root.configController.fileDisplay : "" @@ -805,6 +986,11 @@ ApplicationWindow { onInspectionExploreRequested: root.inspectionExploreRequested() onValidateRequested: root.datasetValidateRequested() pageKey: root.currentPage + preparationDraft: root.configController !== null + ? root.configController.preparationDraft : null + preparationWorkflowController: root.preparationWorkflowController + sweepDraft: root.configController !== null ? root.configController.sweepDraft : null + sweepWorkflowController: root.sweepWorkflowController workspacePath: root.controllerAvailable ? root.desktopController.workspaceRootPath : "" workspaceState: root.controllerAvailable ? root.desktopController.workspaceState : @@ -844,6 +1030,9 @@ ApplicationWindow { datasetAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable inspectAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable runAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable + sweepsAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable + preparationAvailable: root.controllerAvailable + && root.desktopController.workspaceAvailable visualizationAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable yamlAvailable: root.configController !== null && root.configController.hasDocument @@ -869,6 +1058,7 @@ ApplicationWindow { blockingSection: root.configController !== null ? root.configController.blockingSection : "none" closeButtonVisible: true + configController: root.configController configurationDirty: root.configController !== null && root.configController.dirty configurationFile: root.configController !== null ? root.configController.fileDisplay : "" @@ -886,6 +1076,11 @@ ApplicationWindow { onInspectionExploreRequested: root.inspectionExploreRequested() onValidateRequested: root.datasetValidateRequested() pageKey: root.currentPage + preparationDraft: root.configController !== null + ? root.configController.preparationDraft : null + preparationWorkflowController: root.preparationWorkflowController + sweepDraft: root.configController !== null ? root.configController.sweepDraft : null + sweepWorkflowController: root.sweepWorkflowController workspacePath: root.controllerAvailable ? root.desktopController.workspaceRootPath : "" workspaceState: root.controllerAvailable ? root.desktopController.workspaceState : "unavailable" @@ -922,8 +1117,10 @@ ApplicationWindow { parentPath, childName) onInitializeWorkspaceRequested: path => root.workspaceInitializeRequested(path) onOpenWorkspaceRequested: path => root.workspaceOpenRequested(path) - onImportDatasetRequested: path => root.requestDatasetImport(path) + onImportConfigurationRequested: path => root.requestConfigurationImport(path) onNewDatasetRequested: mode => root.requestDatasetNew(mode) + onNewPreparationRequested: root.requestPreparationNew() + onNewSweepRequested: root.requestSweepNew() } } @@ -966,7 +1163,7 @@ ApplicationWindow { id: yamlPage YamlPreviewPage { - configController: root.desktopController.datasetConfigController + configController: root.desktopController.configurationController objectName: "yamlPreviewPage" onAttentionRequested: (section, field, row) => root.configurationAttentionRequested( section, field, row) @@ -974,6 +1171,116 @@ ApplicationWindow { } } + Component { + id: modelSweepPage + + ModelSweepPage { + attentionField: root.pendingAttentionField + attentionRow: root.pendingAttentionRow + attentionSerial: root.pendingAttentionSerial + configController: root.configController + desktopController: root.desktopController + expectedColumns: root.cardColumnCount + objectName: "modelSweepPage" + onWorkspaceRequested: root.routeTo("workspace") + sweepDraft: root.configController.sweepDraft + workflowController: root.sweepWorkflowController + } + } + + Component { + id: preparationPage + + PreparationPage { + attentionField: root.pendingAttentionField + attentionRow: root.pendingAttentionRow + attentionSerial: root.pendingAttentionSerial + configController: root.configController + desktopController: root.desktopController + expectedColumns: root.cardColumnCount + inspectionController: root.inspectionController + objectName: "preparationPage" + onArrayFormatSelectionRequested: (value, selected) + => root.preparationArrayFormatSelectionRequested(value, + selected) + onBaselineModelSelectionRequested: (value, selected) + => root.preparationBaselineModelSelectionRequested( + value, selected) + onBooleanFieldRequested: (field, value) => root.preparationBooleanFieldRequested(field, + value) + onCategoricalSelectionRequested: (field, selected) + => root.preparationCategoricalSelectionRequested(field, + selected) + onCategoryModeRequested: (field, mode, confirmed) + => root.preparationCategoryModeRequested(field, mode, + confirmed) + onExplicitCategoriesRequested: (field, values) + => root.preparationExplicitCategoriesRequested(field, + values) + onInspectSourceRequested: root.routeTo("inspect") + onNewPreparationRequested: root.requestPreparationNew() + onRoleSelectionRequested: (role, value, selected) + => root.preparationRoleSelectionRequested(role, value, + selected) + onScenarioAddRequested: root.preparationScenarioAddRequested() + onScenarioCancelRequested: root.preparationScenarioCancelRequested() + onScenarioCategoricalHoldoutRequested: (draft, partition, values) + => root.preparationScenarioCategoricalHoldoutRequested( + draft, partition, values) + onScenarioCommitRequested: root.preparationScenarioCommitRequested() + onScenarioCoordinateHoldoutRequested: (draft, partition, field, minimum, maximum) + => root.preparationScenarioCoordinateHoldoutRequested( + draft, partition, field, minimum, maximum) + onScenarioEditRequested: row => root.preparationScenarioEditRequested(row) + onScenarioFieldChangeRequested: (draft, field, value) + => root.preparationScenarioFieldChangeRequested(draft, + field, value) + onScenarioKindChangeRequested: (draft, kind, confirmed) + => root.preparationScenarioKindChangeRequested(draft, + kind, confirmed) + onScenarioMoveRequested: (source, destination) => root.preparationScenarioMoveRequested( + source, destination) + onScenarioNumericBinsRequested: (draft, field, boundaries) + => root.preparationScenarioNumericBinsRequested(draft, + field, boundaries) + onScenarioPartitionRequested: (draft, partition, ratio) + => root.preparationScenarioPartitionRequested(draft, + partition, + ratio) + onScenarioRangeHoldoutRequested: (draft, partition, minimum, maximum) + => root.preparationScenarioRangeHoldoutRequested(draft, + partition, + minimum, maximum) + onScenarioRemoveHoldoutRequested: (draft, partition) + => root.preparationScenarioRemoveHoldoutRequested( + draft, partition) + onScenarioRemoveNumericBinsRequested: (draft, field) + => root.preparationScenarioRemoveNumericBinsRequested( + draft, field) + onScenarioRemovePartitionRequested: (draft, partition) + => root.preparationScenarioRemovePartitionRequested( + draft, partition) + onScenarioRemoveRequested: row => root.preparationScenarioRemoveRequested(row) + onScenarioStrataRequested: (draft, fields) => root.preparationScenarioStrataRequested( + draft, fields) + onScenarioTransformationAddRequested: (draft, field, methods) + => root.preparationScenarioTransformationAddRequested( + draft, field, methods) + onScenarioTransformationMoveRequested: (draft, source, destination) + => root.preparationScenarioTransformationMoveRequested( + draft, source, destination) + onScenarioTransformationRemoveRequested: (draft, row) + => root.preparationScenarioTransformationRemoveRequested( + draft, row) + onSourceBindRequested: root.preparationSourceBindRequested() + onSourceClearRequested: confirmed => root.preparationSourceClearRequested(confirmed) + onTextFieldRequested: (field, value) => root.preparationTextFieldRequested(field, value) + onWorkspaceRequested: root.routeTo("workspace") + preparationDraft: root.configController.preparationDraft + workflowController: root.preparationWorkflowController + } + } + Component { id: visualizationPage @@ -1057,8 +1364,12 @@ ApplicationWindow { InspectPage { inspectionController: root.inspectionController objectName: "inspectPage" + preparationWorkflowController: root.preparationWorkflowController onInspectSourceRequested: path => root.inspectionInspectRequested(path) onMoreSourcesRequested: root.inspectionMoreSourcesRequested() + onPreparationRequested: root.routeTo("preparation") + onPreparationSourceBindRequested: root.preparationSourceBindRequested() + onPreparationSourceClearRequested: root.preparationSourceClearRequested(false) onPreviewPageRequested: pageOffset => root.inspectionPreviewPageRequested(pageOffset) onRefreshRequested: root.inspectionRefreshRequested() onRefreshSourcesRequested: root.inspectionSourcesRefreshRequested() @@ -1116,15 +1427,17 @@ ApplicationWindow { function onBusyShutdownConfirmationRequested(mode, message) { busyShutdownDialog.busyMode = mode; busyShutdownDialog.bodyText = message; - busyShutdownDialog.acceptText = mode === "cancel_generation" ? qsTr("Cancel and close") : - qsTr("Force stop and close"); + busyShutdownDialog.acceptText = mode === "force_stop_plot" ? qsTr( + "Force stop and close") : + qsTr("Cancel and close"); busyShutdownDialog.open(); } function onAttentionRequested(section, field, row) { - if (section !== "dataset" && section !== "visualization") + if (section !== "dataset" && section !== "sweep" && section !== "preparation" && section + !== "visualization") return; - root.routeTo(section); + root.routeTo(section === "sweep" ? "sweeps" : section); root.pendingAttentionField = field; root.pendingAttentionRow = row; root.pendingAttentionSerial += 1; @@ -1138,8 +1451,12 @@ ApplicationWindow { datasetDecisionDialog.open(); } - function onDatasetDocumentOpened() { - root.routeTo("dataset"); + function onConfigurationDocumentOpened(documentKind) { + root.routeTo(documentKind === "dataset" ? "dataset" : (documentKind === "model_sweep" + ? "sweeps" : (documentKind + === "preparation" + ? "preparation" : + "yaml"))); } function onNavigationRequested(pageKey, detail) { @@ -1156,6 +1473,10 @@ ApplicationWindow { }); } + function onPreparationSourceClearConfirmationRequested() { + preparationSourceClearDialog.open(); + } + function onShutdownConfirmationRequested() { shutdownDiscardDialog.open(); } @@ -1169,7 +1490,8 @@ ApplicationWindow { const workspaceAvailable = root.desktopController.workspaceAvailable; if ((root.currentPage === "dataset" || root.currentPage === "run" || root.currentPage === "inspect" || root.currentPage === "visualization" || root.currentPage - === "activity") && !workspaceAvailable) + === "activity" || root.currentPage === "sweeps" || root.currentPage + === "preparation") && !workspaceAvailable) root.routeTo("workspace"); if (root.currentPage === "yaml" && (root.configController === null || !root.configController.hasDocument)) @@ -1242,10 +1564,14 @@ ApplicationWindow { const mode = root.pendingReplacementMode; root.pendingReplacementMode = ""; root.datasetNewRequested(mode, true); + } else if (action === "new_sweep") { + root.sweepNewRequested(true); + } else if (action === "new_preparation") { + root.preparationNewRequested(true); } else if (action === "import") { const path = root.pendingReplacementPath; root.pendingReplacementPath = ""; - root.datasetImportRequested(path, true); + root.configurationImportRequested(path, true); } else if (action === "close") { root.datasetCloseRequested(true); } @@ -1312,7 +1638,19 @@ ApplicationWindow { onAccepted: root.transientEditShutdownConfirmed(true) onRejected: root.transientEditShutdownConfirmed(false) rejectText: qsTr("Keep open") - title: qsTr("Unfinished plot edit") + title: qsTr("Unfinished edit") + } + + DecisionDialog { + id: preparationSourceClearDialog + + acceptText: qsTr("Clear source") + bodyText: qsTr( + "Clearing the bound ML Preparation source makes the current Preparation plan stale. The Preparation configuration itself is not changed.") + objectName: "preparationSourceClearDialog" + onAccepted: root.preparationSourceClearRequested(true) + rejectText: qsTr("Keep source") + title: qsTr("Clear ML Preparation source?") } DecisionDialog { @@ -1326,8 +1664,13 @@ ApplicationWindow { onAccepted: root.busyShutdownConfirmed(true) onRejected: root.busyShutdownConfirmed(false) rejectText: qsTr("Keep open") - title: busyMode === "force_stop_plot" ? qsTr("Stop plot render and close?") : qsTr( - "Cancel generation and close?") + title: busyMode === "force_stop_plot" ? qsTr("Stop plot render and close?") : (busyMode + === "cancel_sweep" + ? qsTr("Cancel Model Sweep and close?") : + (busyMode + === "cancel_preparation" + ? qsTr("Cancel ML Preparation and close?") : + qsTr("Cancel generation and close?"))) } FileDialog { @@ -1338,7 +1681,7 @@ ApplicationWindow { nameFilters: [qsTr("YAML configurations (*.yaml *.yml)")] objectName: "saveConfigurationDialog" parentWindow: root - title: qsTr("Save dataset configuration") + title: qsTr("Save configuration") onAccepted: { root.saveSelectionPath = selectedFile.toString(); root.saveSelectionAccepted = true; diff --git a/src/carnopy/app/qml/Carnopy/components/AppButton.qml b/src/carnopy/app/qml/Carnopy/components/AppButton.qml index 5509696..b5ec3b0 100644 --- a/src/carnopy/app/qml/Carnopy/components/AppButton.qml +++ b/src/carnopy/app/qml/Carnopy/components/AppButton.qml @@ -10,6 +10,7 @@ Button { property string iconName: "" property color iconColor: foregroundColor property bool compact: false + readonly property bool iconOnly: compact && iconName.length > 0 property color foregroundColor: { if (!control.enabled) return Theme.textSubtle; @@ -24,14 +25,14 @@ Button { activeFocusOnTab: enabled hoverEnabled: true implicitHeight: 36 - implicitWidth: compact ? 36 : Math.max(82, contentRow.implicitWidth + 22) - leftPadding: compact ? 8 : 11 - rightPadding: compact ? 8 : 11 + implicitWidth: iconOnly ? 36 : Math.max(82, contentRow.implicitWidth + 22) + leftPadding: iconOnly ? 8 : 11 + rightPadding: iconOnly ? 8 : 11 contentItem: RowLayout { id: contentRow - spacing: control.compact || control.iconName.length === 0 ? 0 : 8 + spacing: control.iconOnly || control.iconName.length === 0 ? 0 : 8 AppIcon { Layout.alignment: Qt.AlignVCenter @@ -49,8 +50,8 @@ Button { font.pixelSize: 13 font.weight: control.tone === "primary" ? Font.Medium : Font.Normal horizontalAlignment: Text.AlignHCenter - text: control.compact ? "" : control.text - visible: !control.compact + text: control.text + visible: !control.iconOnly } } diff --git a/src/carnopy/app/qml/Carnopy/components/ComparisonPlotEditor.qml b/src/carnopy/app/qml/Carnopy/components/ComparisonPlotEditor.qml new file mode 100644 index 0000000..ac56084 --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/components/ComparisonPlotEditor.qml @@ -0,0 +1,387 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Carnopy + +Card { + id: root + + required property var desktopController + required property var draft + property string attentionField: "" + property int attentionRow: -1 + property int attentionSerial: 0 + property bool locked: false + + signal cancelRequested + signal commitRequested + + function focusField(field, row) { + let target = nameField; + if (field.endsWith(".kind")) + target = kindChoice; + else if (field.endsWith(".fluid")) + target = fluidChoice; + else if (field.endsWith(".property")) + target = propertyChoice; + else if (field.endsWith(".x")) + target = xChoice; + else if (field.endsWith(".group_by")) + target = groupChoice; + else if (field.endsWith(".models")) + target = explicitModels; + else if (field.endsWith(".delta_metric")) + target = deltaMetricChoice; + else if (field.endsWith(".value_scale")) + target = valueScaleChoice; + else if (field.endsWith(".format")) + target = formatChoice; + else if (field.endsWith(".filters")) { + filtersEditor.focusRow(row); + return; + } + target.forceActiveFocus(); + } + + onAttentionSerialChanged: Qt.callLater(function () { + root.focusField(root.attentionField, root.attentionRow); + }) + + Layout.fillWidth: true + meta: root.draft.locallyValid ? qsTr("Ready to commit") : qsTr("Needs attention") + metaColor: root.draft.locallyValid ? Theme.success : Theme.danger + objectName: "comparisonPlotEditor" + subtitle: qsTr( + "Edits stay temporary until Commit. Save, Plan, and Execute never include this draft implicitly.") + title: qsTr("Comparison plot draft") + + ValidationIssue { + Layout.fillWidth: true + field: root.draft.firstInvalidField + issue: root.draft.issue + objectName: "comparisonPlotEditorIssue" + } + + GridLayout { + Layout.fillWidth: true + columnSpacing: Theme.spacingMedium + columns: width >= 680 ? 2 : 1 + rowSpacing: Theme.spacingSmall + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Unique name") + } + + TextField { + id: nameField + + Accessible.name: qsTr("Comparison plot name") + Layout.fillWidth: true + enabled: !root.locked + objectName: "comparisonPlotName" + onEditingFinished: root.desktopController.requestSweepComparisonFieldChange( + root.draft, "name", text) + selectByMouse: true + text: root.draft.name + } + } + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Kind") + } + + AppComboBox { + id: kindChoice + + Accessible.name: qsTr("Comparison plot kind") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.kind) + enabled: !root.locked + model: root.draft.kindChoices + objectName: "comparisonPlotKind" + onActivated: root.desktopController.requestSweepComparisonFieldChange(root.draft, + "kind", String( + currentValue)) + } + } + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Fluid") + } + + AppComboBox { + id: fluidChoice + + Accessible.name: qsTr("Comparison plot fluid") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.fluid) + enabled: !root.locked + model: root.draft.fluidChoices + objectName: "comparisonPlotFluid" + onActivated: root.desktopController.requestSweepComparisonFieldChange(root.draft, + "fluid", String( + currentValue)) + } + } + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Property") + } + + AppComboBox { + id: propertyChoice + + Accessible.name: qsTr("Comparison plot property") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.propertyName) + enabled: !root.locked + model: root.draft.propertyChoices + objectName: "comparisonPlotProperty" + onActivated: root.desktopController.requestSweepComparisonFieldChange(root.draft, + "property", + String(currentValue)) + } + } + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("X coordinate") + } + + AppComboBox { + id: xChoice + + Accessible.name: qsTr("Comparison plot X coordinate") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.xField) + enabled: !root.locked + model: root.draft.xChoices + objectName: "comparisonPlotX" + onActivated: root.desktopController.requestSweepComparisonFieldChange(root.draft, + "x", String( + currentValue)) + } + } + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Group by") + } + + AppComboBox { + id: groupChoice + + Accessible.name: qsTr("Comparison plot grouping") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.groupBy) + enabled: !root.locked + model: root.draft.groupByChoices + objectName: "comparisonPlotGroupBy" + onActivated: root.desktopController.requestSweepComparisonFieldChange(root.draft, + "group_by", + String(currentValue)) + } + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.draft.kind === "property_delta" + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Delta metric") + } + + AppComboBox { + id: deltaMetricChoice + + Accessible.name: qsTr("Comparison delta metric") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.deltaMetric) + enabled: !root.locked + model: root.draft.deltaMetricChoices + objectName: "comparisonPlotDeltaMetric" + onActivated: root.desktopController.requestSweepComparisonFieldChange(root.draft, + "delta_metric", + String(currentValue)) + } + } + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Value scale") + } + + AppComboBox { + id: valueScaleChoice + + Accessible.name: qsTr("Comparison value scale") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.valueScale) + enabled: !root.locked + model: root.draft.scaleChoices + objectName: "comparisonPlotValueScale" + onActivated: root.desktopController.requestSweepComparisonFieldChange(root.draft, + "value_scale", + String(currentValue)) + } + } + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Per-plot format override") + } + + AppComboBox { + id: formatChoice + + Accessible.name: qsTr("Comparison plot format override") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.outputFormat) + enabled: !root.locked + model: root.draft.formatChoices + objectName: "comparisonPlotFormat" + onActivated: root.desktopController.requestSweepComparisonFieldChange(root.draft, + "format", String( + currentValue)) + } + } + } + + CheckBox { + id: explicitModels + + Accessible.description: qsTr("Otherwise every selected non-reference model is included") + Accessible.name: qsTr("Choose an explicit comparison model subset") + checked: root.draft.explicitModels + enabled: !root.locked + objectName: "comparisonPlotExplicitModels" + onClicked: root.desktopController.requestSweepComparisonExplicitModels(root.draft, checked) + text: qsTr("Use an explicit model subset") + } + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + visible: root.draft.explicitModels + + Repeater { + model: root.draft.modelChoices + + delegate: Item { + required property bool compatible + required property string display + required property string issue + required property bool selected + required property string value + + implicitHeight: modelCheck.implicitHeight + implicitWidth: modelCheck.implicitWidth + + CheckBox { + id: modelCheck + + Accessible.description: parent.issue + Accessible.name: qsTr("Include %1 in comparison").arg(parent.display) + checked: parent.selected + enabled: !root.locked && parent.compatible + objectName: "comparisonPlotModel-" + parent.value + onClicked: root.desktopController.requestSweepComparisonModelSelection( + root.draft, parent.value, checked) + text: parent.display + } + } + } + } + + MappingEditor { + id: filtersEditor + + Layout.fillWidth: true + emptyText: qsTr("No row filters configured") + locked: root.locked + mappingModel: root.draft.filtersModel + noun: qsTr("comparison filter") + objectName: "comparisonPlotFilters" + onAddRequested: model => root.desktopController.requestSweepComparisonFilterAdd(model) + onFieldChangeRequested: (model, row, field) + => root.desktopController.requestSweepComparisonFilterFieldChange( + model, row, field) + onRemoveRequested: (model, row) => root.desktopController.requestSweepComparisonFilterRemove( + model, row) + onValueChangeRequested: (model, row, value) + => root.desktopController.requestSweepComparisonFilterValueChange( + model, row, value) + } + + RowLayout { + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + AppButton { + enabled: !root.locked + objectName: "comparisonPlotCancelButton" + onClicked: root.cancelRequested() + text: qsTr("Cancel") + } + + AppButton { + enabled: !root.locked && root.draft.locallyValid + objectName: "comparisonPlotCommitButton" + onClicked: root.commitRequested() + text: qsTr("Commit") + tone: "primary" + } + } +} diff --git a/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml b/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml index 07d9acc..f9c6858 100644 --- a/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml +++ b/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml @@ -15,11 +15,16 @@ Control { property bool configurationDirty: false property string configurationFile: "" property bool configurationOpen: false + property var configController: null property bool datasetValid: false property string datasetIssue: "" property var executionController: null property var inspectionController: null property var activityController: null + property var preparationDraft: null + property var preparationWorkflowController: null + property var sweepDraft: null + property var sweepWorkflowController: null property string pageKey: "workspace" property bool visualizationActiveEdit: false property string visualizationIssue: "" @@ -100,7 +105,7 @@ Control { flickableDirection: Flickable.VerticalFlick pixelAligned: true visible: root.pageKey !== "run" && root.pageKey !== "inspect" && root.pageKey - !== "activity" + !== "activity" && root.pageKey !== "sweeps" && root.pageKey !== "preparation" ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded @@ -322,5 +327,48 @@ Control { activityController: root.activityController visible: root.pageKey === "activity" && root.activityController !== null } + + WorkflowContextInspector { + Layout.fillHeight: true + Layout.fillWidth: true + configController: root.configController + firstInvalidField: root.sweepDraft !== null ? root.sweepDraft.firstInvalidField : "" + firstInvalidRow: root.sweepDraft !== null ? root.sweepDraft.firstInvalidRow : -1 + localIssue: root.sweepDraft !== null ? root.sweepDraft.issue : "" + localValid: root.sweepDraft !== null && root.sweepDraft.locallyValid + objectName: "sweepWorkflowContextInspector" + onAttentionRequested: (section, field, row) => root.attentionRequested(section, field, + row) + onValidateRequested: root.validateRequested() + visible: root.pageKey === "sweeps" && root.sweepDraft !== null + && root.sweepWorkflowController !== null + transientEditActive: root.sweepDraft !== null && root.sweepDraft.hasActiveComparisonEdit + workflowController: root.sweepWorkflowController + workflowSection: "sweep" + workflowTitle: qsTr("Model Sweep") + } + + WorkflowContextInspector { + Layout.fillHeight: true + Layout.fillWidth: true + configController: root.configController + firstInvalidField: root.preparationDraft !== null + ? root.preparationDraft.firstInvalidField : "" + firstInvalidRow: root.preparationDraft !== null ? root.preparationDraft.firstInvalidRow : + -1 + localIssue: root.preparationDraft !== null ? root.preparationDraft.issue : "" + localValid: root.preparationDraft !== null && root.preparationDraft.locallyValid + objectName: "preparationWorkflowContextInspector" + onAttentionRequested: (section, field, row) => root.attentionRequested(section, field, + row) + onValidateRequested: root.validateRequested() + visible: root.pageKey === "preparation" && root.preparationDraft !== null + && root.preparationWorkflowController !== null + transientEditActive: root.preparationDraft !== null + && root.preparationDraft.hasActiveScenarioEdit + workflowController: root.preparationWorkflowController + workflowSection: "preparation" + workflowTitle: qsTr("ML Preparation") + } } } diff --git a/src/carnopy/app/qml/Carnopy/components/NavRail.qml b/src/carnopy/app/qml/Carnopy/components/NavRail.qml index b466182..4ea20aa 100644 --- a/src/carnopy/app/qml/Carnopy/components/NavRail.qml +++ b/src/carnopy/app/qml/Carnopy/components/NavRail.qml @@ -15,6 +15,8 @@ Control { property bool inspectAvailable: false property bool activityAvailable: false property bool runAvailable: false + property bool sweepsAvailable: false + property bool preparationAvailable: false property bool visualizationAvailable: false property bool yamlAvailable: false readonly property alias collapseControl: railCollapseButton @@ -103,15 +105,15 @@ Control { pageKey: "sweeps" title: qsTr("Model Sweeps") iconName: "git-compare-arrows" - available: false - unavailableReason: qsTr("Model-sweep workflow migration follows the core GUI-2 stages.") + available: true + unavailableReason: qsTr("Open a workspace before using Model Sweeps.") } ListElement { pageKey: "preparation" title: qsTr("ML Preparation") iconName: "flask-conical" - available: false - unavailableReason: qsTr("ML preparation remains outside the active GUI-2 stage.") + available: true + unavailableReason: qsTr("Open a workspace before using ML Preparation.") } ListElement { pageKey: "three-d" @@ -208,7 +210,11 @@ Control { && (pageKey !== "inspect" || root.inspectAvailable) && ( pageKey !== "activity" - || root.activityAvailable) + || root.activityAvailable) && ( + pageKey !== "sweeps" + || root.sweepsAvailable) && ( + pageKey !== "preparation" + || root.preparationAvailable) Accessible.description: effectivelyAvailable ? "" : unavailableReason Accessible.name: title diff --git a/src/carnopy/app/qml/Carnopy/components/PreparationAuditView.qml b/src/carnopy/app/qml/Carnopy/components/PreparationAuditView.qml new file mode 100644 index 0000000..f4710d9 --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/components/PreparationAuditView.qml @@ -0,0 +1,831 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Carnopy + +Item { + id: root + + required property var audit + property int selectedSection: 0 + readonly property int expectedColumns: width >= 960 ? 2 : 1 + + function displayStatus(value) { + if (value === undefined || value === null || String(value).length === 0) + return qsTr("Not recorded"); + const normalized = String(value).replace(/_/g, " "); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); + } + + function formatNumber(value) { + const number = Number(value); + if (!Number.isFinite(number)) + return String(value); + if (number === 0) + return "0"; + if (Math.abs(number) >= 100000 || Math.abs(number) < 0.001) + return number.toExponential(4); + return Number(number.toPrecision(6)).toString(); + } + + function formatOptional(value, available) { + return available ? formatNumber(value) : qsTr("Not recorded"); + } + + function formatFraction(value, available) { + return available ? qsTr("%1%").arg(formatNumber(Number(value) * 100)) : qsTr( + "Not recorded"); + } + + function formatBoolean(value, available) { + if (!available) + return qsTr("Not recorded"); + return value ? qsTr("Yes") : qsTr("No"); + } + + function scenarioText(value) { + return String(value).length > 0 ? String(value) : qsTr("All scenarios"); + } + + function listText(value) { + if (value === undefined || value === null || value.length === 0) + return qsTr("None recorded"); + return value.join(", "); + } + + function qualityTone(status) { + if (status === "completed") + return "success"; + if (status === "completed_with_failures" || status === "unavailable") + return "warning"; + if (status === "failed") + return "danger"; + return "neutral"; + } + + implicitHeight: auditColumn.implicitHeight + implicitWidth: 720 + objectName: "preparationAuditView" + + component EvidenceRow: Rectangle { + id: evidenceRow + + required property string detail + required property string heading + property string metadata: "" + property string warning: "" + + Accessible.description: detail + (metadata.length > 0 ? ". " + metadata : "") + ( + warning.length > 0 ? ". " + warning : "") + Accessible.name: heading + border.color: Theme.divider + border.width: 1 + color: Theme.surfaceRaised + implicitHeight: evidenceRowColumn.implicitHeight + 20 + radius: Theme.radiusSmall + width: ListView.view ? ListView.view.width : (parent ? parent.width : 0) + + ColumnLayout { + id: evidenceRowColumn + + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Label { + Layout.fillWidth: true + color: Theme.text + font.family: Theme.sansFamily + font.pixelSize: 13 + font.weight: Font.Medium + text: evidenceRow.heading + wrapMode: Text.Wrap + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.monoFamily + font.pixelSize: 10 + text: evidenceRow.detail + wrapMode: Text.Wrap + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 10 + text: evidenceRow.metadata + visible: text.length > 0 + wrapMode: Text.Wrap + } + + Label { + Layout.fillWidth: true + color: Theme.warning + font.family: Theme.sansFamily + font.pixelSize: 10 + text: evidenceRow.warning + visible: text.length > 0 + wrapMode: Text.Wrap + } + } + } + + component EvidenceCard: Card { + id: evidenceCard + + required property string accessibleName + required property var evidenceModel + required property string listObjectName + required property Component rowDelegate + property string emptyText: qsTr("No findings were recorded.") + property string unavailableText: qsTr("This evidence was not recorded for the source.") + + Layout.fillWidth: true + meta: evidenceModel.available ? qsTr("%1 row(s)").arg(evidenceModel.count) : qsTr( + "Unavailable") + + ListView { + id: evidenceList + + Accessible.name: evidenceCard.accessibleName + Layout.fillWidth: true + Layout.preferredHeight: Math.max(0, Math.min(280, contentHeight)) + activeFocusOnTab: count > 0 + boundsBehavior: Flickable.StopAtBounds + clip: true + delegate: evidenceCard.rowDelegate + interactive: contentHeight > height + model: evidenceCard.evidenceModel + objectName: evidenceCard.listObjectName + pixelAligned: true + reuseItems: true + spacing: Theme.spacingTiny + visible: evidenceCard.evidenceModel.available && count > 0 + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: evidenceCard.evidenceModel.available ? evidenceCard.emptyText : + evidenceCard.unavailableText + visible: !evidenceCard.evidenceModel.available || evidenceCard.evidenceModel.count === 0 + wrapMode: Text.Wrap + } + } + + ColumnLayout { + id: auditColumn + + spacing: Theme.spacingMedium + width: parent.width + + Card { + Layout.fillWidth: true + objectName: "preparationAuditSummaryCard" + subtitle: root.audit.available ? qsTr( + "Verified, finalized Preparation evidence is projected through fixed typed models. Counts and values retain the source revision accepted by Inspect.") : + qsTr("This source has no current typed Preparation audit projection. Legacy bundles may not contain these diagnostics.") + title: qsTr("Preparation audit") + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + StatusBadge { + label: root.audit.available ? root.displayStatus(root.audit.qualityStatus) : + qsTr("Unavailable") + objectName: "preparationAuditStatus" + tone: root.audit.available ? root.qualityTone(root.audit.qualityStatus) : + "neutral" + } + + Item { + Layout.fillWidth: true + } + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Finalized evidence") + visible: root.audit.available + } + } + + Repeater { + model: root.audit.qualityOverview + + delegate: EvidenceRow { + required property string baselineStatus + required property string duplicateStatus + required property int eligibleRowCount + required property bool eligibleRowCountAvailable + required property int errorCount + required property int excludedRowCount + required property bool excludedRowCountAvailable + required property bool flagCountMatches + required property string gridStatus + required property int inspectedFlagCount + required property bool inspectedFlagCountAvailable + required property string matrixStatus + required property int recordedFlagCount + required property bool recordedFlagCountAvailable + required property string scenarioStatus + + Layout.fillWidth: true + detail: qsTr("Eligible %1 · excluded %2 · quality flags %3 / %4").arg( + root.formatOptional(eligibleRowCount, + eligibleRowCountAvailable)).arg( + root.formatOptional(excludedRowCount, + excludedRowCountAvailable)).arg( + root.formatOptional(inspectedFlagCount, + inspectedFlagCountAvailable)).arg( + root.formatOptional(recordedFlagCount, recordedFlagCountAvailable)) + heading: qsTr("Finalized row and quality summary") + metadata: qsTr( + "Scenarios: %1 · matrix: %2 · baselines: %3 · duplicates: %4 · grid: %5").arg( + root.displayStatus(scenarioStatus)).arg(root.displayStatus( + matrixStatus)).arg( + root.displayStatus(baselineStatus)).arg(root.displayStatus( + duplicateStatus)).arg( + root.displayStatus(gridStatus)) + warning: errorCount > 0 ? qsTr("%1 audit issue(s) were recorded.").arg( + errorCount) : ((recordedFlagCountAvailable + && inspectedFlagCountAvailable && + !flagCountMatches) ? qsTr( + "Recorded and inspected quality-flag counts differ.") : + "") + } + } + } + + TabBar { + id: auditSections + + Layout.fillWidth: true + currentIndex: root.selectedSection + objectName: "preparationAuditSections" + onCurrentIndexChanged: root.selectedSection = currentIndex + + TabButton { + Accessible.name: qsTr("Preparation quality and scenario audit") + objectName: "preparationAuditOverviewTab" + text: qsTr("Quality and scenarios") + } + + TabButton { + Accessible.name: qsTr("Preparation matrix audit") + objectName: "preparationAuditMatrixTab" + text: qsTr("Matrix") + } + + TabButton { + Accessible.name: qsTr("Preparation baseline audit") + objectName: "preparationAuditBaselineTab" + text: qsTr("Baselines") + } + } + + StackLayout { + Layout.fillWidth: true + currentIndex: root.selectedSection + objectName: "preparationAuditSectionStack" + + ResponsiveCardGrid { + Layout.fillWidth: true + maximumColumns: root.expectedColumns + minimumCardWidth: 360 + objectName: "preparationAuditOverviewGrid" + uniformHeights: false + + EvidenceCard { + accessibleName: qsTr("Preparation scenarios") + emptyText: qsTr("No Preparation scenarios were recorded.") + evidenceModel: root.audit.scenariosModel + listObjectName: "preparationAuditScenariosList" + subtitle: qsTr( + "Committed scenarios and their finalized row, partition, transformation, and leakage-evidence counts.") + title: qsTr("Scenarios") + rowDelegate: Component { + EvidenceRow { + required property string kind + required property bool leakageAvailable + required property string name + required property int partitionCount + required property int rowCount + required property int transformationCount + + detail: qsTr( + "%1 · %2 row(s) · %3 partition(s) · %4 transformation(s)").arg( + root.displayStatus(kind)).arg(rowCount).arg( + partitionCount).arg(transformationCount) + heading: name + metadata: leakageAvailable ? qsTr("State-leakage evidence recorded") : + qsTr("State-leakage evidence unavailable") + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation partitions") + emptyText: qsTr("No finalized partition counts were recorded.") + evidenceModel: root.audit.partitionsModel + listObjectName: "preparationAuditPartitionsList" + subtitle: qsTr( + "Finalized row counts, retained in scenario and partition order.") + title: qsTr("Partitions") + rowDelegate: Component { + EvidenceRow { + required property string partition + required property int rowCount + required property string scenario + + detail: qsTr("%1 row(s)").arg(rowCount) + heading: qsTr("%1 · %2").arg(scenario).arg(partition) + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation state leakage checks") + emptyText: qsTr("No state-leakage checks were recorded.") + evidenceModel: root.audit.leakageAudits + listObjectName: "preparationAuditLeakageList" + subtitle: qsTr( + "Duplicate states are counted independently from states crossing finalized partition boundaries.") + title: qsTr("State leakage") + rowDelegate: Component { + EvidenceRow { + required property int crossPartitionGroupCount + required property int duplicateStateGroupCount + required property string identityColumn + required property string scenario + + detail: qsTr("Duplicate groups %1 · cross-partition groups %2").arg( + duplicateStateGroupCount).arg(crossPartitionGroupCount) + heading: scenario + metadata: qsTr("Identity: %1").arg(identityColumn) + warning: crossPartitionGroupCount > 0 ? qsTr( + "This scenario contains states shared across partitions.") : + "" + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation duplicate state checks") + emptyText: qsTr("No duplicate-state check was recorded.") + evidenceModel: root.audit.duplicateStateChecks + listObjectName: "preparationAuditDuplicateStatesList" + subtitle: qsTr( + "Candidate duplicate source states and conflicting target groups.") + title: qsTr("Duplicate states") + rowDelegate: Component { + EvidenceRow { + required property bool countsAvailable + required property int conflictingTargetGroupCount + required property int duplicateGroupCount + required property int duplicateRowCount + required property var groupColumns + required property string identityColumn + required property string status + + detail: countsAvailable ? qsTr( + "Groups %1 · rows %2 · target conflicts %3").arg( + duplicateGroupCount).arg( + duplicateRowCount).arg( + conflictingTargetGroupCount) : qsTr( + "Counts were not available for this check.") + heading: root.displayStatus(status) + metadata: qsTr("Grouped by %1%2").arg(root.listText(groupColumns)).arg( + identityColumn.length > 0 ? qsTr(" · identity %1").arg( + identityColumn) : "") + warning: countsAvailable && conflictingTargetGroupCount > 0 ? qsTr( + "Conflicting targets were recorded for duplicate states.") : + "" + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation structured grid groups") + emptyText: qsTr("No completed structured-grid groups were recorded.") + evidenceModel: root.audit.gridGroups + listObjectName: "preparationAuditGridGroupsList" + subtitle: qsTr( + "Coverage, repeated cells, and phase-boundary evidence by source group.") + title: qsTr("Structured grid") + rowDelegate: Component { + EvidenceRow { + required property string backendModel + required property bool coverageAvailable + required property real coverageFraction + required property int expectedCells + required property int missingCells + required property int multiPhaseCellCount + required property int observedCells + required property string phaseBoundaryStatus + required property int repeatedCellCount + required property int repeatedRowCount + required property int rowCount + required property string sourceFluid + required property string sourceRunId + required property int transitionEdgeCount + + detail: qsTr("Coverage %1 · %2 / %3 cells · %4 missing · %5 row(s)").arg( + root.formatFraction(coverageFraction, + coverageAvailable)).arg( + observedCells).arg(expectedCells).arg(missingCells).arg( + rowCount) + heading: qsTr("%1 · %2 · %3").arg(sourceRunId).arg(sourceFluid).arg( + backendModel) + metadata: qsTr( + "Repeated cells %1 (%2 rows) · phase %3 · multi-phase cells %4 · transition edges %5").arg( + repeatedCellCount).arg(repeatedRowCount).arg( + root.displayStatus(phaseBoundaryStatus)).arg( + multiPhaseCellCount).arg(transitionEdgeCount) + warning: missingCells > 0 || repeatedCellCount > 0 ? qsTr( + "Grid coverage contains missing or repeated cells.") : + "" + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation structured grid spacing") + emptyText: qsTr("No coordinate-spacing evidence was recorded.") + evidenceModel: root.audit.gridSpacing + listObjectName: "preparationAuditGridSpacingList" + subtitle: qsTr( + "Coordinate extent and spacing evidence in deterministic group order.") + title: qsTr("Grid spacing") + rowDelegate: Component { + EvidenceRow { + required property string coordinate + required property int groupOrder + required property int levelCount + required property real maximum + required property bool maximumAvailable + required property real maximumSpacing + required property bool maximumSpacingAvailable + required property real medianSpacing + required property bool medianSpacingAvailable + required property real minimum + required property bool minimumAvailable + required property real minimumSpacing + required property bool minimumSpacingAvailable + required property int spacingCount + required property real spacingRatio + required property bool spacingRatioAvailable + required property bool uniformSpacing + required property bool uniformSpacingAvailable + + detail: qsTr("Levels %1 · range %2 to %3 · spacing samples %4").arg( + levelCount).arg(root.formatOptional(minimum, + minimumAvailable)).arg( + root.formatOptional(maximum, maximumAvailable)).arg( + spacingCount) + heading: qsTr("Group %1 · %2").arg(groupOrder + 1).arg(coordinate) + metadata: qsTr( + "Spacing min %1 · median %2 · max %3 · ratio %4 · uniform %5").arg( + root.formatOptional(minimumSpacing, + minimumSpacingAvailable)).arg( + root.formatOptional(medianSpacing, + medianSpacingAvailable)).arg( + root.formatOptional(maximumSpacing, + maximumSpacingAvailable)).arg( + root.formatOptional(spacingRatio, + spacingRatioAvailable)).arg( + root.formatBoolean(uniformSpacing, + uniformSpacingAvailable)) + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation structured grid phase counts") + emptyText: qsTr("No grid phase counts were recorded.") + evidenceModel: root.audit.gridPhaseCounts + listObjectName: "preparationAuditGridPhasesList" + subtitle: qsTr("Observed phase counts for each structured-grid group.") + title: qsTr("Grid phases") + rowDelegate: Component { + EvidenceRow { + required property int count + required property int groupOrder + required property string phase + + detail: qsTr("%1 row(s)").arg(count) + heading: qsTr("Group %1 · %2").arg(groupOrder + 1).arg(phase) + } + } + } + } + + ResponsiveCardGrid { + Layout.fillWidth: true + maximumColumns: root.expectedColumns + minimumCardWidth: 360 + objectName: "preparationAuditMatrixGrid" + uniformHeights: false + + EvidenceCard { + accessibleName: qsTr("Preparation matrix checks") + emptyText: qsTr( + "No matrix fits were recorded; inspect the status above for whether diagnostics were requested.") + evidenceModel: root.audit.matrixChecks + listObjectName: "preparationAuditMatrixChecksList" + subtitle: qsTr( + "Rank, effective rank, condition, and configured thresholds by fit context.") + title: qsTr("Matrix checks") + rowDelegate: Component { + EvidenceRow { + required property real conditionNumber + required property bool conditionNumberAvailable + required property bool conditionNumberInfinite + required property real correlationThreshold + required property bool correlationThresholdAvailable + required property real effectiveRank + required property bool effectiveRankAvailable + required property real effectiveRankFraction + required property bool effectiveRankFractionAvailable + required property int featureCount + required property real featureRankFraction + required property bool featureRankFractionAvailable + required property string fitPartition + required property real nearConstantThreshold + required property bool nearConstantThresholdAvailable + required property int numericalRank + required property bool numericalRankAvailable + required property real rankTolerance + required property bool rankToleranceAvailable + required property string rankToleranceDefinition + required property int rowCount + required property string scenario + required property string status + required property int targetCount + + detail: qsTr( + "%1 row(s) · %2 feature(s) · %3 target(s) · numerical rank %4 (%5) · effective rank %6 (%7)").arg( + rowCount).arg(featureCount).arg(targetCount).arg( + root.formatOptional(numericalRank, + numericalRankAvailable)).arg( + root.formatFraction(featureRankFraction, + featureRankFractionAvailable)).arg( + root.formatOptional(effectiveRank, + effectiveRankAvailable)).arg( + root.formatFraction(effectiveRankFraction, + effectiveRankFractionAvailable)) + heading: qsTr("%1 · fit %2 · %3").arg(root.scenarioText(scenario)).arg( + fitPartition).arg(root.displayStatus(status)) + metadata: qsTr( + "Condition %1%2 · rank tolerance %3 (%4) · correlation threshold %5 · near-constant threshold %6").arg( + conditionNumberInfinite ? qsTr("Infinite") : + root.formatOptional( + conditionNumber, + conditionNumberAvailable)).arg( + conditionNumberInfinite ? qsTr(" (recorded)") : "").arg( + root.formatOptional(rankTolerance, + rankToleranceAvailable)).arg( + rankToleranceDefinition.length > 0 + ? rankToleranceDefinition : qsTr("not recorded")).arg( + root.formatOptional(correlationThreshold, + correlationThresholdAvailable)).arg( + root.formatOptional(nearConstantThreshold, + nearConstantThresholdAvailable)) + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation matrix feature flags") + emptyText: qsTr( + "No constant or near-constant feature or target flags were recorded.") + evidenceModel: root.audit.matrixFeatureFlags + listObjectName: "preparationAuditMatrixFlagsList" + subtitle: qsTr( + "Constant and near-constant findings retain their scenario and fit partition.") + title: qsTr("Feature and target flags") + rowDelegate: Component { + EvidenceRow { + required property string field + required property string fitPartition + required property string kind + required property real relativeSpread + required property bool relativeSpreadAvailable + required property string scenario + + detail: qsTr("%1 · relative spread %2").arg(root.displayStatus( + kind)).arg( + root.formatOptional(relativeSpread, + relativeSpreadAvailable)) + heading: field + metadata: qsTr("%1 · fit %2").arg(root.scenarioText(scenario)).arg( + fitPartition) + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation singular values") + emptyText: qsTr("No singular values were recorded.") + evidenceModel: root.audit.singularValues + listObjectName: "preparationAuditSingularValuesList" + subtitle: qsTr("Ordered singular values and explained-variance ratios by fit.") + title: qsTr("Singular values") + rowDelegate: Component { + EvidenceRow { + required property real explainedVarianceRatio + required property string fitPartition + required property int order + required property string scenario + required property real singularValue + + detail: qsTr("Value %1 · explained variance %2").arg(root.formatNumber( + singularValue)).arg( + root.formatFraction(explainedVarianceRatio, true)) + heading: qsTr("%1 · fit %2 · singular value %3").arg(root.scenarioText( + scenario)).arg( + fitPartition).arg(order + 1) + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation correlated feature pairs") + emptyText: qsTr("No highly correlated feature pairs were recorded.") + evidenceModel: root.audit.correlatedFeaturePairs + listObjectName: "preparationAuditCorrelatedPairsList" + subtitle: qsTr("Pairs exceeding the configured absolute-correlation threshold.") + title: qsTr("Correlated feature pairs") + rowDelegate: Component { + EvidenceRow { + required property real correlation + required property string fitPartition + required property var model + required property string scenario + + detail: qsTr("Correlation %1").arg(root.formatNumber(correlation)) + heading: qsTr("%1 ↔ %2").arg(model.left).arg(model.right) + metadata: qsTr("%1 · fit %2").arg(root.scenarioText(scenario)).arg( + fitPartition) + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation feature target correlations") + emptyText: qsTr("No feature-target correlations were recorded.") + evidenceModel: root.audit.featureTargetCorrelations + listObjectName: "preparationAuditFeatureTargetList" + subtitle: qsTr( + "Recorded feature-target relationships by scenario and fit partition.") + title: qsTr("Feature-target correlations") + rowDelegate: Component { + EvidenceRow { + required property real correlation + required property string feature + required property string fitPartition + required property string scenario + required property string target + + detail: qsTr("Correlation %1").arg(root.formatNumber(correlation)) + heading: qsTr("%1 → %2").arg(feature).arg(target) + metadata: qsTr("%1 · fit %2").arg(root.scenarioText(scenario)).arg( + fitPartition) + } + } + } + } + + ResponsiveCardGrid { + Layout.fillWidth: true + maximumColumns: root.expectedColumns + minimumCardWidth: 360 + objectName: "preparationAuditBaselineGrid" + uniformHeights: false + + EvidenceCard { + accessibleName: qsTr("Preparation baseline checks") + emptyText: qsTr( + "No baseline fits were recorded; inspect the status above for whether diagnostics were requested.") + evidenceModel: root.audit.baselineChecks + listObjectName: "preparationAuditBaselineChecksList" + subtitle: qsTr( + "Diagnostic-only baseline execution context and completion counts.") + title: qsTr("Baseline checks") + rowDelegate: Component { + EvidenceRow { + required property int completedModelCount + required property int evaluationPartitionCount + required property int evaluationRowCount + required property int failedModelCount + required property int featureCount + required property string library + required property string libraryVersion + required property string policy + required property string scenario + required property string status + required property int targetCount + required property int trainRowCount + required property bool trainRowCountAvailable + + detail: qsTr( + "Train rows %1 · %2 feature(s) · %3 target(s) · %4 evaluation partition(s), %5 row(s)").arg( + root.formatOptional(trainRowCount, + trainRowCountAvailable)).arg( + featureCount).arg(targetCount).arg( + evaluationPartitionCount).arg(evaluationRowCount) + heading: qsTr("%1 · %2").arg(root.scenarioText(scenario)).arg( + root.displayStatus(status)) + metadata: qsTr( + "%1 %2 · completed models %3 · failed models %4 · %5").arg( + library).arg(libraryVersion).arg(completedModelCount).arg( + failedModelCount).arg(policy) + warning: failedModelCount > 0 ? qsTr( + "One or more diagnostic baseline fits failed.") : + "" + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation baseline metrics") + emptyText: qsTr("No baseline metrics were recorded.") + evidenceModel: root.audit.baselineMetrics + listObjectName: "preparationAuditBaselineMetricsList" + subtitle: qsTr( + "Metrics and actual/prediction ranges retain model, target, and partition context.") + title: qsTr("Baseline metrics") + rowDelegate: Component { + EvidenceRow { + required property real actualMaximum + required property real actualMinimum + required property real meanAbsoluteError + required property string model + required property string partition + required property real predictionMaximum + required property real predictionMinimum + required property real rSquared + required property bool rSquaredAvailable + required property real rootMeanSquaredError + required property string scenario + required property string target + + detail: qsTr("MAE %1 · RMSE %2 · R² %3").arg(root.formatNumber( + meanAbsoluteError)).arg( + root.formatNumber(rootMeanSquaredError)).arg( + root.formatOptional(rSquared, rSquaredAvailable)) + heading: qsTr("%1 · %2 · %3").arg(model).arg(target).arg(partition) + metadata: qsTr("%1 · actual %2 to %3 · prediction %4 to %5").arg( + root.scenarioText(scenario)).arg(root.formatNumber( + actualMinimum)).arg( + root.formatNumber(actualMaximum)).arg(root.formatNumber( + predictionMinimum)).arg( + root.formatNumber(predictionMaximum)) + } + } + } + + EvidenceCard { + accessibleName: qsTr("Preparation baseline failures") + emptyText: qsTr("No baseline fit failures were recorded.") + evidenceModel: root.audit.baselineFailures + listObjectName: "preparationAuditBaselineFailuresList" + subtitle: qsTr( + "Fit failures remain visible without replacing successful diagnostic results.") + title: qsTr("Baseline failures") + rowDelegate: Component { + EvidenceRow { + required property string errorType + required property string message + required property string model + required property string scenario + required property string target + + detail: qsTr("%1: %2").arg(errorType).arg(message) + heading: qsTr("%1 · %2").arg(model).arg(target) + metadata: root.scenarioText(scenario) + warning: qsTr( + "Diagnostic fit failed; successful results remain inspectable.") + } + } + } + } + } + } +} diff --git a/src/carnopy/app/qml/Carnopy/components/PreparationScenarioEditor.qml b/src/carnopy/app/qml/Carnopy/components/PreparationScenarioEditor.qml new file mode 100644 index 0000000..39a2c34 --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/components/PreparationScenarioEditor.qml @@ -0,0 +1,709 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Carnopy + +Card { + id: root + + required property var desktopController + required property var draft + property string attentionField: "" + property int attentionRow: -1 + property int attentionSerial: 0 + property bool dialogsEnabled: true + property bool locked: false + property string pendingKind: "" + readonly property bool ratioKind: draft.kind === "shuffle" || draft.kind === "stratified_hash" + readonly property bool holdoutKind: ["coordinate_block", "range_holdout", "leave_fluid_out", + "phase_holdout", "model_holdout"].indexOf(draft.kind) >= 0 + readonly property bool categoricalHoldoutKind: ["leave_fluid_out", "phase_holdout", + "model_holdout"].indexOf(draft.kind) >= 0 + + signal cancelRequested + signal commitRequested + signal kindChangeDialogRequested + signal scenarioCategoricalHoldoutRequested(var draft, string partition, string values) + signal scenarioCoordinateHoldoutRequested(var draft, string partition, string field, + string minimum, string maximum) + signal scenarioFieldChangeRequested(var draft, string field, string value) + signal scenarioKindChangeRequested(var draft, string kind, bool confirmed) + signal scenarioNumericBinsRequested(var draft, string field, string boundaries) + signal scenarioPartitionRequested(var draft, string partition, string ratio) + signal scenarioRangeHoldoutRequested(var draft, string partition, string minimum, + string maximum) + signal scenarioRemoveHoldoutRequested(var draft, string partition) + signal scenarioRemoveNumericBinsRequested(var draft, string field) + signal scenarioRemovePartitionRequested(var draft, string partition) + signal scenarioStrataRequested(var draft, string fields) + signal scenarioTransformationAddRequested(var draft, string field, string methods) + signal scenarioTransformationMoveRequested(var draft, int source, int destination) + signal scenarioTransformationRemoveRequested(var draft, int row) + + function focusField(field, row) { + let target = nameField; + if (field.endsWith(".kind")) + target = kindChoice; + else if (field.endsWith(".seed")) + target = seedField; + else if (field.endsWith(".field")) + target = rangeFieldChoice; + else if (field.endsWith(".partitions")) { + partitionsList.currentIndex = row; + partitionsList.forceActiveFocus(); + return; + } else if (field.endsWith(".holdouts")) { + holdoutsList.currentIndex = row; + holdoutsList.forceActiveFocus(); + return; + } else if (field.endsWith(".strata")) + target = strataFields; + else if (field.endsWith(".transformations")) { + transformationsList.currentIndex = row; + transformationsList.forceActiveFocus(); + return; + } + target.forceActiveFocus(); + } + + onAttentionSerialChanged: Qt.callLater(function () { + root.focusField(root.attentionField, root.attentionRow); + }) + + Layout.fillWidth: true + meta: root.draft.locallyValid ? qsTr("Ready to commit") : qsTr("Needs attention") + metaColor: root.draft.locallyValid ? Theme.success : Theme.danger + objectName: "preparationScenarioEditor" + subtitle: qsTr( + "Edits stay temporary until Commit. Save, Plan, and Execute never include this draft implicitly.") + title: qsTr("Preparation scenario draft") + + ValidationIssue { + Layout.fillWidth: true + field: root.draft.firstInvalidField + issue: root.draft.issue + objectName: "preparationScenarioEditorIssue" + } + + GridLayout { + Layout.fillWidth: true + columnSpacing: Theme.spacingMedium + columns: width >= 680 ? 2 : 1 + objectName: "preparationScenarioBasicsGrid" + rowSpacing: Theme.spacingSmall + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Unique name") + } + + TextField { + id: nameField + + Accessible.name: qsTr("Scenario name") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationScenarioName" + onEditingFinished: root.scenarioFieldChangeRequested(root.draft, "name", text) + selectByMouse: true + text: root.draft.name + } + } + + ColumnLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Scenario kind") + } + + AppComboBox { + id: kindChoice + + Accessible.name: qsTr("Scenario kind") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.kind) + enabled: !root.locked + model: root.draft.kindChoices + objectName: "preparationScenarioKind" + onActivated: { + const selected = String(currentValue); + if (selected === root.draft.kind) + return; + root.pendingKind = selected; + root.kindChangeDialogRequested(); + } + } + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.ratioKind + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Seed") + } + + TextField { + id: seedField + + Accessible.name: qsTr("Scenario random seed") + Layout.fillWidth: true + enabled: !root.locked + inputMethodHints: Qt.ImhDigitsOnly + objectName: "preparationScenarioSeed" + onEditingFinished: root.scenarioFieldChangeRequested(root.draft, "seed", text) + selectByMouse: true + text: root.draft.seedText + } + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.draft.kind === "range_holdout" + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Range field") + } + + AppComboBox { + id: rangeFieldChoice + + Accessible.name: qsTr("Range holdout field") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.field) + enabled: !root.locked + model: root.draft.fieldChoices + objectName: "preparationScenarioField" + onActivated: root.scenarioFieldChangeRequested(root.draft, "field", String( + currentValue)) + } + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.holdoutKind + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Remainder partition") + } + + AppComboBox { + id: remainderChoice + + Accessible.name: qsTr("Scenario remainder partition") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.draft.remainder) + enabled: !root.locked + model: ["train", "validation", "test"] + objectName: "preparationScenarioRemainder" + onActivated: root.scenarioFieldChangeRequested(root.draft, "remainder", String( + currentValue)) + } + } + } + + Label { + Layout.fillWidth: true + color: Theme.information + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr( + "Unsplit uses the implied ‘all’ partition and has no held-out partition for fitting transformations.") + visible: root.draft.kind === "unsplit" + wrapMode: Text.Wrap + } + + Card { + Layout.fillWidth: true + flat: true + objectName: "preparationScenarioPartitionsCard" + subtitle: qsTr( + "Partition ratios are deterministic configuration values validated by Carnopy’s public schema.") + title: qsTr("Partitions") + visible: root.ratioKind + + ListView { + id: partitionsList + + Accessible.name: qsTr("Scenario partitions") + Layout.fillWidth: true + Layout.preferredHeight: Math.max(44, Math.min(176, contentHeight)) + boundsBehavior: Flickable.StopAtBounds + clip: true + model: root.draft.partitionsModel + objectName: "preparationScenarioPartitions" + spacing: Theme.spacingTiny + + delegate: RowLayout { + id: partitionRow + + required property int index + required property string partition + required property real ratio + + width: ListView.view.width + + Label { + Layout.preferredWidth: 110 + color: Theme.text + font.family: Theme.sansFamily + font.pixelSize: 12 + text: partitionRow.partition + } + + TextField { + Layout.fillWidth: true + Accessible.name: qsTr("Ratio for %1 partition").arg(partitionRow.partition) + enabled: !root.locked + objectName: "preparationScenarioPartitionRatio-" + partitionRow.index + onEditingFinished: root.scenarioPartitionRequested(root.draft, + partitionRow.partition, text) + selectByMouse: true + text: String(partitionRow.ratio) + } + + AppButton { + compact: true + enabled: !root.locked + onClicked: root.scenarioRemovePartitionRequested(root.draft, + partitionRow.partition) + text: qsTr("Remove") + } + } + } + + RowLayout { + Layout.fillWidth: true + + AppComboBox { + id: partitionName + + Accessible.name: qsTr("Partition to add") + Layout.fillWidth: true + model: ["train", "validation", "test"] + objectName: "preparationScenarioPartitionName" + } + + TextField { + id: partitionRatio + + Accessible.name: qsTr("Partition ratio") + Layout.fillWidth: true + objectName: "preparationScenarioPartitionRatio" + placeholderText: qsTr("0.2") + selectByMouse: true + } + + AppButton { + enabled: !root.locked + onClicked: root.scenarioPartitionRequested(root.draft, String( + partitionName.currentValue), + partitionRatio.text) + text: qsTr("Set") + } + } + } + + Card { + Layout.fillWidth: true + flat: true + objectName: "preparationScenarioHoldoutsCard" + subtitle: root.categoricalHoldoutKind ? qsTr( + "Enter exact source categories or backend models as comma-separated values.") : + qsTr("Range and coordinate bounds are inclusive scientific configuration values.") + title: qsTr("Holdouts") + visible: root.holdoutKind + + ListView { + id: holdoutsList + + Accessible.name: qsTr("Scenario holdouts") + Layout.fillWidth: true + Layout.preferredHeight: Math.max(44, Math.min(176, contentHeight)) + boundsBehavior: Flickable.StopAtBounds + clip: true + model: root.draft.holdoutsModel + objectName: "preparationScenarioHoldouts" + spacing: Theme.spacingTiny + + delegate: RowLayout { + id: holdoutRow + + required property int index + required property string partition + required property string summary + + width: ListView.view.width + + Label { + Layout.preferredWidth: 100 + color: Theme.text + font.family: Theme.sansFamily + font.pixelSize: 12 + text: holdoutRow.partition + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.monoFamily + font.pixelSize: 11 + text: holdoutRow.summary + wrapMode: Text.WrapAnywhere + } + + AppButton { + compact: true + enabled: !root.locked + onClicked: root.scenarioRemoveHoldoutRequested(root.draft, holdoutRow.partition) + text: qsTr("Remove") + } + } + } + + GridLayout { + Layout.fillWidth: true + columns: width >= 680 ? 3 : 1 + + AppComboBox { + id: holdoutPartition + + Accessible.name: qsTr("Holdout partition") + Layout.fillWidth: true + model: ["validation", "test"] + objectName: "preparationScenarioHoldoutPartition" + } + + AppComboBox { + id: holdoutField + + Accessible.name: qsTr("Coordinate holdout field") + Layout.fillWidth: true + model: root.draft.fieldChoices + objectName: "preparationScenarioHoldoutField" + visible: root.draft.kind === "coordinate_block" + } + + TextField { + id: categoricalValues + + Accessible.name: qsTr("Categorical holdout values") + Layout.fillWidth: true + objectName: "preparationScenarioHoldoutValues" + placeholderText: qsTr("value-a, value-b") + selectByMouse: true + visible: root.categoricalHoldoutKind + } + + TextField { + id: minimumValue + + Accessible.name: qsTr("Holdout minimum") + Layout.fillWidth: true + objectName: "preparationScenarioHoldoutMinimum" + placeholderText: qsTr("Minimum") + selectByMouse: true + visible: !root.categoricalHoldoutKind + } + + TextField { + id: maximumValue + + Accessible.name: qsTr("Holdout maximum") + Layout.fillWidth: true + objectName: "preparationScenarioHoldoutMaximum" + placeholderText: qsTr("Maximum") + selectByMouse: true + visible: !root.categoricalHoldoutKind + } + + AppButton { + enabled: !root.locked + objectName: "preparationScenarioSetHoldoutButton" + onClicked: { + const partition = String(holdoutPartition.currentValue); + if (root.categoricalHoldoutKind) + root.scenarioCategoricalHoldoutRequested(root.draft, partition, + categoricalValues.text); + else if (root.draft.kind === "range_holdout") + root.scenarioRangeHoldoutRequested(root.draft, partition, minimumValue.text, + maximumValue.text); + else + root.scenarioCoordinateHoldoutRequested(root.draft, partition, String( + holdoutField.currentValue), + minimumValue.text, maximumValue.text); + } + text: qsTr("Set holdout") + } + } + } + + Card { + Layout.fillWidth: true + flat: true + objectName: "preparationScenarioStratificationCard" + subtitle: qsTr( + "Categorical fields and numeric bin boundaries form the deterministic hash key.") + title: qsTr("Stratification") + visible: root.draft.kind === "stratified_hash" + + TextField { + id: strataFields + + Accessible.name: qsTr("Categorical strata fields") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationScenarioStrataFields" + onEditingFinished: root.scenarioStrataRequested(root.draft, text) + placeholderText: qsTr("fluid, phase") + selectByMouse: true + text: root.draft.strataCategoricalText + } + + RowLayout { + Layout.fillWidth: true + + AppComboBox { + id: binField + + Accessible.name: qsTr("Numeric bin field") + Layout.fillWidth: true + model: root.draft.fieldChoices + objectName: "preparationScenarioBinField" + } + + TextField { + id: binBoundaries + + Accessible.name: qsTr("Numeric bin boundaries") + Layout.fillWidth: true + objectName: "preparationScenarioBinBoundaries" + placeholderText: qsTr("250, 300, 350") + selectByMouse: true + } + + AppButton { + enabled: !root.locked + onClicked: root.scenarioNumericBinsRequested(root.draft, String( + binField.currentValue), + binBoundaries.text) + text: qsTr("Set bins") + } + } + + ListView { + Accessible.name: qsTr("Scenario numeric bins") + Layout.fillWidth: true + Layout.preferredHeight: Math.max(36, Math.min(144, contentHeight)) + boundsBehavior: Flickable.StopAtBounds + clip: true + model: root.draft.numericBinsModel + objectName: "preparationScenarioNumericBins" + + delegate: RowLayout { + id: numericBinRow + + required property string field + required property string summary + + width: ListView.view.width + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.monoFamily + font.pixelSize: 11 + text: numericBinRow.field + ": " + numericBinRow.summary + } + + AppButton { + compact: true + enabled: !root.locked + onClicked: root.scenarioRemoveNumericBinsRequested(root.draft, + numericBinRow.field) + text: qsTr("Remove") + } + } + } + } + + Card { + Layout.fillWidth: true + flat: true + objectName: "preparationScenarioTransformationsCard" + subtitle: qsTr( + "Order is preserved. Every transformation is fitted on the training partition only and then applied to validation and test rows.") + title: qsTr("Transformations") + + ListView { + id: transformationsList + + Accessible.name: qsTr("Scenario transformations") + Layout.fillWidth: true + Layout.preferredHeight: Math.max(44, Math.min(176, contentHeight)) + boundsBehavior: Flickable.StopAtBounds + clip: true + model: root.draft.transformationsModel + objectName: "preparationScenarioTransformations" + spacing: Theme.spacingTiny + + delegate: RowLayout { + id: transformationRow + + required property int index + required property string summary + + width: ListView.view.width + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.monoFamily + font.pixelSize: 11 + text: transformationRow.summary + wrapMode: Text.WrapAnywhere + } + + AppButton { + compact: true + enabled: !root.locked && transformationRow.index > 0 + onClicked: root.scenarioTransformationMoveRequested(root.draft, + transformationRow.index, + transformationRow.index - 1) + text: qsTr("Up") + } + + AppButton { + compact: true + enabled: !root.locked && transformationRow.index + 1 < transformationsList.count + onClicked: root.scenarioTransformationMoveRequested(root.draft, + transformationRow.index, + transformationRow.index + 1) + text: qsTr("Down") + } + + AppButton { + compact: true + enabled: !root.locked + onClicked: root.scenarioTransformationRemoveRequested(root.draft, + transformationRow.index) + text: qsTr("Remove") + } + } + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Methods: %1").arg(root.draft.transformationMethodChoices.join(", ")) + wrapMode: Text.Wrap + } + + RowLayout { + Layout.fillWidth: true + + AppComboBox { + id: transformField + + Accessible.name: qsTr("Transformation field") + Layout.fillWidth: true + model: root.draft.fieldChoices + objectName: "preparationScenarioTransformationField" + } + + TextField { + id: transformMethods + + Accessible.name: qsTr("Ordered transformation methods") + Layout.fillWidth: true + objectName: "preparationScenarioTransformationMethods" + placeholderText: qsTr("log10, standard") + selectByMouse: true + } + + AppButton { + enabled: !root.locked + onClicked: root.scenarioTransformationAddRequested(root.draft, String( + transformField.currentValue), + transformMethods.text) + text: qsTr("Add") + } + } + } + + RowLayout { + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + AppButton { + enabled: !root.locked + objectName: "preparationScenarioCancelButton" + onClicked: root.cancelRequested() + text: qsTr("Cancel") + } + + AppButton { + enabled: !root.locked && root.draft.locallyValid + objectName: "preparationScenarioCommitButton" + onClicked: root.commitRequested() + text: qsTr("Commit") + tone: "primary" + } + } + + Loader { + id: kindChangeDialogLoader + + active: root.dialogsEnabled && root.Window.window !== null + objectName: "preparationScenarioKindChangeDialogLoader" + sourceComponent: Component { + DecisionDialog { + id: scenarioKindDialog + + acceptText: qsTr("Change kind") + bodyText: qsTr( + "Changing scenario kind discards temporary partitions, holdouts, strata, field, and remainder values that do not belong to the new shape. The seed and transformations are retained.") + objectName: "preparationScenarioKindChangeDialog" + onAccepted: { + root.scenarioKindChangeRequested(root.draft, root.pendingKind, true); + root.pendingKind = ""; + } + onRejected: root.pendingKind = "" + title: qsTr("Replace scenario shape?") + + Connections { + function onKindChangeDialogRequested() { + scenarioKindDialog.open(); + } + + target: root + } + } + } + } +} diff --git a/src/carnopy/app/qml/Carnopy/components/WorkflowContextInspector.qml b/src/carnopy/app/qml/Carnopy/components/WorkflowContextInspector.qml new file mode 100644 index 0000000..73340cd --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/components/WorkflowContextInspector.qml @@ -0,0 +1,266 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Carnopy + +Flickable { + id: root + + required property var configController + required property string firstInvalidField + required property int firstInvalidRow + required property string localIssue + required property bool localValid + required property bool transientEditActive + required property var workflowController + required property string workflowSection + required property string workflowTitle + readonly property bool documentActive: configController.documentKind + === workflowController.documentKind + + signal attentionRequested(string section, string field, int row) + signal validateRequested + + function stateLabel(value) { + const labels = { + "unavailable": qsTr("Unavailable"), + "ready": qsTr("Ready"), + "starting": qsTr("Starting"), + "running": qsTr("Running"), + "planned": qsTr("Planned"), + "validated": qsTr("Validated"), + "invalid": qsTr("Invalid"), + "cancellation_requested": qsTr("Cancelling"), + "force_stopping": qsTr("Force stopping"), + "succeeded": qsTr("Succeeded"), + "failed": qsTr("Failed"), + "cancelled": qsTr("Cancelled"), + "force_stopped": qsTr("Force stopped") + }; + return labels[value] || value; + } + + boundsBehavior: Flickable.StopAtBounds + clip: true + contentHeight: workflowInspectorColumn.implicitHeight + contentWidth: width + flickableDirection: Flickable.VerticalFlick + objectName: "workflowContextInspector" + pixelAligned: true + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ColumnLayout { + id: workflowInspectorColumn + + spacing: Theme.spacingMedium + width: parent.width + + Card { + flat: true + Layout.fillWidth: true + subtitle: root.documentActive ? (root.configController.fileDisplay.length > 0 + ? root.configController.fileDisplay : qsTr( + "Save this new configuration before planning.")) : + qsTr("No %1 configuration is active. Historical results remain available below.").arg( + root.workflowTitle) + title: qsTr("%1 document").arg(root.workflowTitle) + + StatusBadge { + label: !root.documentActive ? qsTr("Not active") : (root.configController.dirty ? qsTr( + "Unsaved") : + qsTr("Saved")) + objectName: "workflowInspectorDocumentState" + tone: !root.documentActive ? "neutral" : (root.configController.dirty ? "warning" : + "success") + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.divider + } + + Card { + flat: true + Layout.fillWidth: true + subtitle: !root.documentActive ? qsTr("Open or create a %1 configuration.").arg( + root.workflowTitle) : (root.localValid ? qsTr( + "Every structured field is locally complete.") : + root.localIssue) + title: qsTr("Structured draft") + + StatusBadge { + label: !root.documentActive ? qsTr("Not available") : (root.transientEditActive + ? qsTr("Temporary edit open") : + (root.localValid ? qsTr( + "Locally complete") : + qsTr("Needs attention"))) + objectName: "workflowInspectorDraftState" + tone: !root.documentActive ? "neutral" : (root.transientEditActive ? "warning" : ( + root.localValid + ? "success" : + "danger")) + } + + AppButton { + Layout.fillWidth: true + enabled: root.documentActive && !root.localValid + objectName: "workflowInspectorDraftFocusButton" + onClicked: root.attentionRequested(root.workflowSection, root.firstInvalidField, + root.firstInvalidRow) + text: qsTr("Focus first issue") + visible: root.documentActive && !root.localValid + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.divider + } + + Card { + flat: true + Layout.fillWidth: true + subtitle: !root.documentActive ? qsTr( + "No active %1 configuration is available to validate.").arg( + root.workflowTitle) : ( + root.configController.workerValidationIssue.length + > 0 ? root.configController.workerValidationIssue : + qsTr("Worker validation is informational; exact saved bytes remain authoritative for planning.")) + title: qsTr("Worker validation") + + StatusBadge { + label: !root.documentActive ? qsTr("Not available") : ( + root.configController.workerValidationState + === "not_run" ? qsTr("Not run") : root.stateLabel( + root.configController.workerValidationState)) + objectName: "workflowInspectorValidationState" + tone: !root.documentActive ? "neutral" : ( + root.configController.workerValidationState + === "valid" ? "success" : ( + root.configController.workerValidationState + === "invalid" + || root.configController.workerValidationState + === "failed" ? "danger" : + "neutral")) + } + + AppButton { + Layout.fillWidth: true + enabled: root.documentActive && root.configController.canValidate + objectName: "workflowInspectorValidateButton" + onClicked: root.validateRequested() + text: root.configController.workerValidationState === "running" ? qsTr( + "Checking draft…") : + qsTr("Check current draft YAML") + tone: "primary" + visible: root.documentActive + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.divider + } + + Card { + flat: true + Layout.fillWidth: true + subtitle: root.workflowController.hasPlan ? (root.workflowController.planCurrent ? qsTr( + "The plan matches the exact current saved configuration.") : + qsTr("The retained plan is stale relative to current inputs.")) : + qsTr("No worker-verified plan exists yet.") + title: qsTr("Plan relation") + + StatusBadge { + label: root.workflowController.hasPlan ? (root.workflowController.planCurrent ? qsTr( + "Current") : + qsTr("Stale")) : + qsTr("Not planned") + objectName: "workflowInspectorPlanState" + tone: root.workflowController.planCurrent ? "success" : ( + root.workflowController.hasPlan + ? "warning" : "neutral") + } + + Repeater { + model: root.workflowController.planBlockingReasons + + delegate: AppButton { + required property string fieldId + required property int index + required property string message + required property int nestedRow + required property string section + + Layout.fillWidth: true + objectName: "workflowInspectorPlanBlocker-" + index + onClicked: root.attentionRequested(section, fieldId, nestedRow) + text: "• " + message + tone: "quiet" + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.divider + } + + Card { + flat: true + Layout.fillWidth: true + subtitle: root.workflowController.hasResult ? qsTr( + "The finalized result is retained independently of page and document lifetime.") : + qsTr("No finalized result exists in this session.") + title: qsTr("Execution and result") + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + StatusBadge { + label: root.stateLabel(root.workflowController.workflowState) + objectName: "workflowInspectorExecutionState" + tone: root.workflowController.workflowState === "failed" ? "danger" : ( + root.workflowController.operationActive + ? "information" : + "neutral") + } + + StatusBadge { + label: root.workflowController.hasResult ? qsTr("Result %1").arg( + root.workflowController.resultRelation) : + qsTr("No result") + objectName: "workflowInspectorResultState" + tone: root.workflowController.resultRelation === "current" ? "success" : ( + root.workflowController.hasResult + ? "warning" : + "neutral") + } + } + + Label { + Layout.fillWidth: true + color: root.workflowController.protectedFinalization ? Theme.warning : + Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + objectName: "workflowInspectorPhase" + text: root.workflowController.protectedFinalization ? qsTr("Finalizing safely") : + root.workflowController.workflowPhase + visible: text.length > 0 + wrapMode: Text.Wrap + } + } + } +} diff --git a/src/carnopy/app/qml/Carnopy/components/WorkflowRunPanel.qml b/src/carnopy/app/qml/Carnopy/components/WorkflowRunPanel.qml new file mode 100644 index 0000000..9291b5b --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/components/WorkflowRunPanel.qml @@ -0,0 +1,263 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Carnopy + +Card { + id: root + + required property var workflowController + required property string workflowKind + + signal cancelRequested(string workflow) + signal executeRequested(string workflow) + signal forceStopRequested(string workflow) + signal inspectResultRequested(string workflow) + signal issueFocusRequested(string section, string field, int row) + signal planRequested(string workflow) + + function stateLabel(value) { + const labels = { + "unavailable": qsTr("Unavailable"), + "ready": qsTr("Ready"), + "starting": qsTr("Starting"), + "running": qsTr("Running"), + "planned": qsTr("Planned"), + "validated": qsTr("Validated"), + "invalid": qsTr("Invalid"), + "cancellation_requested": qsTr("Cancelling"), + "force_stopping": qsTr("Force stopping"), + "succeeded": qsTr("Succeeded"), + "failed": qsTr("Failed"), + "cancelled": qsTr("Cancelled"), + "force_stopped": qsTr("Force stopped") + }; + return labels[value] || value; + } + + Layout.fillWidth: true + objectName: root.workflowKind + "WorkflowRunPanel" + subtitle: qsTr( + "Planning and execution remain bound to exact saved bytes and worker-verified context.") + title: qsTr("Plan and execute") + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + StatusBadge { + label: root.stateLabel(root.workflowController.workflowState) + objectName: root.workflowKind + "WorkflowState" + tone: root.workflowController.workflowState === "failed" ? "danger" : ( + root.workflowController.workflowState + === "succeeded" + ? "success" : ( + root.workflowController.operationActive + ? "information" : + "neutral")) + } + + StatusBadge { + label: root.workflowController.hasPlan ? (root.workflowController.planCurrent ? qsTr( + "Plan current") : + qsTr("Plan stale")) : + qsTr("No plan") + objectName: root.workflowKind + "PlanRelation" + tone: root.workflowController.planCurrent ? "success" : ( + root.workflowController.hasPlan + ? "warning" : "neutral") + } + + StatusBadge { + label: root.workflowController.hasResult ? qsTr("Result %1").arg( + root.workflowController.resultRelation) : + qsTr("No result") + objectName: root.workflowKind + "ResultRelation" + tone: root.workflowController.resultRelation === "current" ? "success" : ( + root.workflowController.hasResult + ? "warning" : + "neutral") + } + } + + Label { + Accessible.name: text + Layout.fillWidth: true + color: root.workflowController.protectedFinalization ? Theme.warning : Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 12 + font.weight: root.workflowController.protectedFinalization ? Font.DemiBold : Font.Normal + objectName: root.workflowKind + "WorkflowPhase" + text: root.workflowController.protectedFinalization ? qsTr( + "Finalizing safely — cancellation and force stop are disabled.") : + (root.workflowController.workflowPhase.length + > 0 ? qsTr("Phase: %1").arg( + root.workflowController.workflowPhase) : + qsTr("No worker phase is active.")) + wrapMode: Text.Wrap + } + + ProgressBar { + Accessible.name: qsTr("Workflow progress") + Accessible.description: root.workflowController.progressTotal > 0 ? qsTr("%1 of %2").arg( + root.workflowController.progressCompleted).arg( + root.workflowController.progressTotal) : + qsTr("In progress") + Layout.fillWidth: true + from: 0 + indeterminate: root.workflowController.operationActive + && root.workflowController.progressTotal <= 0 + objectName: root.workflowKind + "WorkflowProgress" + to: Math.max(1, root.workflowController.progressTotal) + value: Math.min(to, root.workflowController.progressCompleted) + visible: root.workflowController.operationActive + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.monoFamily + font.pixelSize: 10 + objectName: root.workflowKind + "PlanIdentity" + text: root.workflowController.planId.length > 0 ? qsTr("Plan %1").arg( + root.workflowController.planId) : "" + visible: text.length > 0 + wrapMode: Text.WrapAnywhere + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingTiny + visible: root.workflowController.planBlockingReasons.count > 0 + + Label { + Layout.fillWidth: true + color: Theme.warning + font.family: Theme.sansFamily + font.pixelSize: 12 + font.weight: Font.Medium + text: qsTr("Planning is unavailable:") + } + + Repeater { + model: root.workflowController.planBlockingReasons + + delegate: AppButton { + required property string fieldId + required property int index + required property string message + required property int nestedRow + required property string section + + Layout.fillWidth: true + objectName: root.workflowKind + "PlanBlocker-" + index + onClicked: root.issueFocusRequested(section, fieldId, nestedRow) + text: "• " + message + tone: "quiet" + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingTiny + visible: root.workflowController.hasPlan + && root.workflowController.executionBlockingReasons.count > 0 + + Label { + Layout.fillWidth: true + color: Theme.warning + font.family: Theme.sansFamily + font.pixelSize: 12 + font.weight: Font.Medium + text: qsTr("Execution is unavailable:") + } + + Repeater { + model: root.workflowController.executionBlockingReasons + + delegate: AppButton { + required property string fieldId + required property int index + required property string message + required property int nestedRow + required property string section + + Layout.fillWidth: true + objectName: root.workflowKind + "ExecutionBlocker-" + index + onClicked: root.issueFocusRequested(section, fieldId, nestedRow) + text: "• " + message + tone: "quiet" + } + } + } + + ValidationIssue { + Layout.fillWidth: true + field: root.workflowController.failureCode + issue: root.workflowController.failureMessage + objectName: root.workflowKind + "WorkflowFailure" + } + + Label { + Layout.fillWidth: true + color: Theme.warning + font.family: Theme.sansFamily + font.pixelSize: 11 + text: root.workflowController.activityPersistenceIssue + visible: text.length > 0 + wrapMode: Text.Wrap + } + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + AppButton { + Accessible.description: qsTr( + "Create a worker-verified plan from the exact saved configuration") + enabled: root.workflowController.canPlan + objectName: root.workflowKind + "PlanButton" + onClicked: root.planRequested(root.workflowKind) + text: qsTr("Plan") + tone: "primary" + } + + AppButton { + Accessible.description: qsTr("Execute the current worker-verified plan") + enabled: root.workflowController.canExecute + objectName: root.workflowKind + "ExecuteButton" + onClicked: root.executeRequested(root.workflowKind) + text: qsTr("Execute") + tone: "primary" + } + + AppButton { + enabled: root.workflowController.cancellationAvailable + objectName: root.workflowKind + "CancelButton" + onClicked: root.cancelRequested(root.workflowKind) + text: qsTr("Cancel") + visible: root.workflowController.operationActive + } + + AppButton { + enabled: root.workflowController.forceStopAvailable + objectName: root.workflowKind + "ForceStopButton" + onClicked: root.forceStopRequested(root.workflowKind) + text: qsTr("Force stop") + tone: "danger" + visible: root.workflowController.workflowState === "cancellation_requested" + || root.workflowController.workflowState === "force_stopping" + } + + AppButton { + enabled: root.workflowController.hasResult + && root.workflowController.resultOutputDirectory.length > 0 + objectName: root.workflowKind + "InspectResultButton" + onClicked: root.inspectResultRequested(root.workflowKind) + text: qsTr("Inspect finalized output") + } + } +} diff --git a/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml b/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml index 0fa5406..fbc5632 100644 --- a/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml @@ -11,8 +11,12 @@ Item { id: root required property var inspectionController + required property var preparationWorkflowController property bool focusTable: false property int selectedTab: 0 + readonly property bool preparationInspectionReady: inspectionController.state === "ready" + && inspectionController.sourceKind + === "preparation" property bool fileSelectionAccepted: false property string fileSelectionPath: "" property bool folderSelectionAccepted: false @@ -20,11 +24,20 @@ Item { signal inspectSourceRequested(string path) signal moreSourcesRequested + signal preparationRequested + signal preparationSourceBindRequested + signal preparationSourceClearRequested signal previewPageRequested(int pageOffset) signal refreshRequested signal refreshSourcesRequested signal selectTableRequested(string tableId) + onPreparationInspectionReadyChanged: { + if (!preparationInspectionReady && selectedTab === 4) { + selectedTab = 0; + } + } + function openExternalFileDialog() { if (root.inspectionController.workspaceOutputsUrl.length > 0) inspectFileDialog.currentFolder = root.inspectionController.workspaceOutputsUrl; @@ -447,6 +460,14 @@ Item { TabButton { text: qsTr("Diagnostics") } + TabButton { + Accessible.description: qsTr( + "Review finalized Preparation quality, scenario, matrix, and baseline evidence") + Accessible.name: qsTr("Preparation audit") + objectName: "inspectionPreparationAuditTab" + text: qsTr("Preparation Audit") + visible: root.preparationInspectionReady + } } AppButton { @@ -484,6 +505,100 @@ Item { spacing: Theme.spacingMedium width: parent.width + Card { + flat: true + Layout.fillWidth: true + objectName: "preparationSourceCard" + subtitle: root.preparationWorkflowController.hasBoundSource + ? root.preparationWorkflowController.boundSourcePath : + qsTr("Inspecting is read-only. An eligible Dataset or Model Sweep becomes Preparation input only after you bind it explicitly.") + title: qsTr("ML Preparation source") + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + StatusBadge { + label: { + if (root.preparationWorkflowController.boundSourceRefreshAvailable) + return qsTr("Refresh available"); + if (root.preparationWorkflowController.inspectedSourceMatchesBinding) + return qsTr("Current inspection bound"); + if (root.preparationWorkflowController.hasBoundSource) + return qsTr("Another source is bound"); + return qsTr("No source bound"); + } + tone: root.preparationWorkflowController.boundSourceRefreshAvailable + ? "warning" : ( + root.preparationWorkflowController.hasBoundSource + ? "success" : "neutral") + } + + AppButton { + enabled: root.preparationWorkflowController.inspectedSourceAvailable + && root.inspectionController.canInspect + objectName: "preparationBindSourceButton" + onClicked: root.preparationSourceBindRequested() + text: root.preparationWorkflowController.boundSourceRefreshAvailable + ? qsTr("Use refreshed source") : ( + root.preparationWorkflowController.inspectedSourceMatchesBinding + ? qsTr("Used for ML Preparation") : qsTr( + "Use for ML Preparation")) + tone: "primary" + visible: root.inspectionController.preparationEligible && + !root.preparationWorkflowController.inspectedSourceMatchesBinding + + ToolTip.text: enabled ? qsTr( + "Bind this exact verified inspection revision for ML Preparation.") : + qsTr("This exact inspection revision is already bound.") + ToolTip.visible: hovered + } + + AppButton { + enabled: root.preparationWorkflowController.hasBoundSource + && root.inspectionController.canInspect + objectName: "preparationClearSourceButton" + onClicked: root.preparationSourceClearRequested() + text: qsTr("Clear source") + tone: "quiet" + visible: root.preparationWorkflowController.hasBoundSource + } + + AppButton { + enabled: root.preparationWorkflowController.hasBoundSource + objectName: "preparationContinueButton" + onClicked: root.preparationRequested() + text: qsTr("Continue to ML Preparation") + tone: "primary" + visible: root.preparationWorkflowController.hasBoundSource + } + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: { + if (root.preparationWorkflowController.sourceBindingIssue.length + > 0) + return root.preparationWorkflowController.sourceBindingIssue; + if (root.inspectionController.state === "ready" && + !root.inspectionController.preparationEligible) + return root.inspectionController.preparationIneligibleReason; + if (root.preparationWorkflowController.hasBoundSource) + return qsTr("Bound %1 · revision %2").arg( + root.preparationWorkflowController.boundSourceKind).arg( + root.preparationWorkflowController.boundSourceRevision.slice( + 0, 12)); + return qsTr( + "No source is currently bound to ML Preparation."); + } + visible: text.length > 0 + wrapMode: Text.Wrap + } + } + Card { flat: true Layout.fillWidth: true @@ -928,23 +1043,80 @@ Item { flat: true Layout.fillWidth: true title: qsTr("Preparation quality errors") - visible: root.inspectionController.preparationQualityErrorsModel.available + visible: root.inspectionController.preparationQualityErrorsModel.count + > 0 + + Repeater { + model: root.inspectionController.preparationQualityErrorsModel + + delegate: Label { + required property string message + + Layout.fillWidth: true + color: Theme.danger + font.family: Theme.sansFamily + font.pixelSize: 12 + text: message + wrapMode: Text.Wrap + } + } + } + } + } + + Flickable { + boundsBehavior: Flickable.StopAtBounds + clip: true + contentHeight: preparationAuditColumn.implicitHeight + contentWidth: width + flickableDirection: Flickable.VerticalFlick + objectName: "inspectionPreparationAuditPane" + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ColumnLayout { + id: preparationAuditColumn + + spacing: Theme.spacingMedium + width: parent.width + + Card { + flat: true + Layout.fillWidth: true + objectName: "inspectionPreparationAuditIssuesCard" + subtitle: qsTr( + "Artifact-level audit issues remain visible without hiding the verified evidence that could be accepted.") + title: qsTr("Preparation audit issues") + visible: root.inspectionController.preparationQualityErrorsModel.count + > 0 Repeater { model: root.inspectionController.preparationQualityErrorsModel delegate: Label { + required property int index required property string message + Accessible.name: qsTr("Preparation audit issue: %1").arg( + message) Layout.fillWidth: true color: Theme.danger font.family: Theme.sansFamily font.pixelSize: 12 + objectName: "inspectionPreparationAuditIssue-" + index text: message wrapMode: Text.Wrap } } } + + PreparationAuditView { + Layout.fillWidth: true + audit: root.inspectionController.preparationAudit + objectName: "inspectionPreparationAuditView" + } } } } diff --git a/src/carnopy/app/qml/Carnopy/pages/ModelSweepPage.qml b/src/carnopy/app/qml/Carnopy/pages/ModelSweepPage.qml new file mode 100644 index 0000000..197095d --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/pages/ModelSweepPage.qml @@ -0,0 +1,676 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Carnopy + +Item { + id: root + + required property var configController + required property var desktopController + required property var sweepDraft + required property var workflowController + property string actionMessage: "" + property string attentionField: "" + property int attentionRow: -1 + property int attentionSerial: 0 + property string comparisonAttentionField: "" + property int comparisonAttentionRow: -1 + property int comparisonAttentionSerial: 0 + property bool dialogsEnabled: true + property int expectedColumns: 1 + property string pendingDatasetChange: "" + property string pendingDatasetValue: "" + property string samplerAttentionAxis: "" + property string samplerAttentionField: "" + property int samplerAttentionSerial: 0 + readonly property var datasetDraft: sweepDraft.datasetDraft + readonly property bool documentActive: configController.documentKind === "model_sweep" + readonly property bool locked: !documentActive || !configController.canEdit + + signal shapeDialogRequested + signal workspaceRequested + + function reveal(item) { + if (item === null || item === undefined) + return; + const position = item.mapToItem(pageFlickable.contentItem, 0, 0); + const maximum = Math.max(0, pageFlickable.contentHeight - pageFlickable.height); + pageFlickable.contentY = Math.min(maximum, Math.max(0, position.y - 80)); + } + + function focusField(field, row) { + let target = modelCard; + if (field === "sweep.backend.reference_model") + target = referenceChoice; + else if (field === "sweep.mode") + target = modeChoice; + else if (field.indexOf("sweep.grid.") === 0) { + const parts = field.split("."); + root.samplerAttentionAxis = parts.length > 2 ? parts[2] : ""; + root.samplerAttentionField = parts.length > 3 ? parts[3] : "unit"; + root.samplerAttentionSerial += 1; + root.reveal(definitionGrid); + return; + } else if (field.indexOf("comparison") >= 0) { + if (root.sweepDraft.activeComparisonDraft !== null) { + const editor = activeComparisonLoader.item; + if (editor !== null) { + root.comparisonAttentionField = field; + root.comparisonAttentionRow = row; + root.comparisonAttentionSerial += 1; + root.reveal(editor); + return; + } + } + target = comparisonCard; + } else if (field.indexOf("properties") >= 0) { + target = propertyChoices.focusRow(row); + } else if (field.indexOf("fluids") >= 0) { + target = fluidChoices.focusRow(row); + } else if (field.indexOf("outputs") >= 0) { + target = outputCard; + } + target.forceActiveFocus(); + root.reveal(target); + } + + onAttentionSerialChanged: Qt.callLater(function () { + root.focusField(root.attentionField, root.attentionRow); + }) + + Connections { + function onMessage(message) { + root.actionMessage = message; + } + + target: root.sweepDraft + } + + Flickable { + id: pageFlickable + + anchors.fill: parent + boundsBehavior: Flickable.StopAtBounds + clip: true + contentHeight: pageColumn.implicitHeight + 48 + contentWidth: width + flickableDirection: Flickable.VerticalFlick + objectName: "modelSweepPageFlickable" + pixelAligned: true + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ColumnLayout { + id: pageColumn + + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.top: parent.top + anchors.topMargin: 22 + spacing: Theme.spacingMedium + + RowLayout { + Layout.fillWidth: true + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + + Label { + Accessible.name: text + Layout.fillWidth: true + color: Theme.text + font.family: Theme.sansFamily + font.pixelSize: 23 + font.weight: Font.DemiBold + text: qsTr("Model Sweep configuration") + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 12 + text: qsTr( + "Compare two or more CoolProp models over one exact, reproducible dataset specification.") + wrapMode: Text.Wrap + } + } + + StatusBadge { + label: !root.documentActive ? qsTr("No Sweep document") : ( + root.sweepDraft.hasActiveComparisonEdit ? qsTr( + "Comparison edit open") : + (root.sweepDraft.locallyValid + ? qsTr("Locally complete") : + qsTr("Needs attention"))) + objectName: "modelSweepLocalState" + tone: !root.documentActive ? "neutral" : ( + root.sweepDraft.hasActiveComparisonEdit + ? "warning" : (root.sweepDraft.locallyValid + ? "success" : "danger")) + } + } + + Card { + Layout.fillWidth: true + objectName: "modelSweepNoDocumentCard" + subtitle: qsTr( + "Create a Model Sweep or open one by its document_type discriminator. A retained finalized result remains inspectable below.") + title: qsTr("No Model Sweep configuration is active") + visible: !root.documentActive + + AppButton { + objectName: "modelSweepOpenWorkspaceButton" + onClicked: root.workspaceRequested() + text: qsTr("Open Workspace") + tone: "primary" + } + } + + BlockingBanner { + Layout.fillWidth: true + field: root.sweepDraft.firstInvalidField + message: root.sweepDraft.issue + row: root.sweepDraft.firstInvalidRow + section: "sweep" + title: qsTr("Sweep configuration needs attention") + visible: root.documentActive && !root.sweepDraft.locallyValid + onActionRequested: (section, field, row) => root.focusField(field, row) + } + + ValidationIssue { + Layout.fillWidth: true + field: root.sweepDraft.firstInvalidField + issue: root.actionMessage + objectName: "modelSweepActionMessage" + } + + ResponsiveCardGrid { + id: definitionGrid + + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + maximumColumns: Math.min(3, root.expectedColumns) + minimumCardWidth: 300 + objectName: "modelSweepDefinitionGrid" + uniformHeights: false + visible: root.documentActive + + Card { + id: modelCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "modelSweepModelsCard" + sectionNumber: "1" + subtitle: qsTr( + "The reference model is implicit in deltas and cannot be removed until another selected model becomes reference.") + title: qsTr("Models and reference") + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + Repeater { + model: root.sweepDraft.modelChoices + + delegate: Item { + required property bool compatible + required property string display + required property string issue + required property bool selected + required property string value + + implicitHeight: modelCheck.implicitHeight + implicitWidth: modelCheck.implicitWidth + + CheckBox { + id: modelCheck + + Accessible.description: parent.issue + Accessible.name: qsTr("Include %1 in model sweep").arg( + parent.display) + checked: parent.selected + enabled: !root.locked && (parent.compatible || parent.selected) + objectName: "modelSweepModel-" + parent.value + onClicked: root.desktopController.requestSweepModelSelection( + parent.value, checked) + text: parent.display + } + } + } + } + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Reference model") + } + + AppComboBox { + id: referenceChoice + + Accessible.description: qsTr( + "Delta plots compare against this selected model") + Accessible.name: qsTr("Sweep reference model") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.sweepDraft.referenceModel) + enabled: !root.locked + model: root.sweepDraft.selectedModels + objectName: "modelSweepReferenceModel" + onActivated: root.desktopController.requestSweepReferenceModel(String( + currentValue)) + } + } + + Card { + Layout.fillWidth: true + objectName: "modelSweepModeCard" + sectionNumber: "2" + subtitle: qsTr( + "Changing mode or independent coordinate replaces only the incompatible sampler shape after confirmation.") + title: qsTr("Dataset mode") + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Mode") + } + + AppComboBox { + id: modeChoice + + Accessible.name: qsTr("Sweep dataset mode") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.datasetDraft.modeName) + enabled: !root.locked + model: root.datasetDraft.modeChoices + objectName: "modelSweepMode" + onActivated: { + root.pendingDatasetChange = "mode"; + root.pendingDatasetValue = String(currentValue); + root.shapeDialogRequested(); + } + textRole: "display" + valueRole: "value" + } + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Independent coordinate") + visible: root.datasetDraft.modeName !== "property_table" + } + + AppComboBox { + Accessible.name: qsTr("Sweep independent coordinate") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.datasetDraft.coordinateName) + enabled: !root.locked + model: root.datasetDraft.coordinateChoices + objectName: "modelSweepCoordinate" + onActivated: { + root.pendingDatasetChange = "coordinate"; + root.pendingDatasetValue = String(currentValue); + root.shapeDialogRequested(); + } + textRole: "display" + valueRole: "value" + visible: root.datasetDraft.modeName !== "property_table" + } + } + + Card { + id: fluidsCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "modelSweepFluidsCard" + sectionNumber: "3" + subtitle: qsTr( + "Aliases and canonical backend identities follow the Dataset contract.") + title: qsTr("Fluids") + + SearchableChoiceList { + id: fluidChoices + + Layout.fillWidth: true + choiceModel: root.datasetDraft.fluidSelectorChoices + locked: root.locked + noun: qsTr("fluid") + objectName: "modelSweepFluids" + onMoveRequested: (row, offset) + => root.desktopController.requestSweepFluidMove(row, + offset) + onRemoveRequested: row => root.desktopController.requestSweepFluidRemove( + row) + onSelectionRequested: (value, selected) + => root.desktopController.requestSweepFluidSelection( + value, selected) + selectedModel: root.datasetDraft.selectedFluids + showCanonicalIdentities: true + summaryLimit: 4 + } + } + + Card { + id: propertiesCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "modelSweepPropertiesCard" + sectionNumber: "4" + subtitle: qsTr( + "Unsupported imported selections remain visible and block planning until resolved.") + title: qsTr("Properties") + + SearchableChoiceList { + id: propertyChoices + + Layout.fillWidth: true + choiceModel: root.datasetDraft.propertySelectorChoices + locked: root.locked + noun: qsTr("property") + objectName: "modelSweepProperties" + onMoveRequested: (row, offset) + => root.desktopController.requestSweepPropertyMove(row, + offset) + onRemoveRequested: row => root.desktopController.requestSweepPropertyRemove( + row) + onSelectionRequested: (value, selected) + => root.desktopController.requestSweepPropertySelection( + value, selected) + selectedModel: root.datasetDraft.selectedProperties + showPropertyPresentation: true + summaryLimit: 6 + } + } + + Repeater { + id: samplerRepeater + + model: root.datasetDraft.samplerDrafts + + delegate: SamplerEditor { + Layout.fillWidth: true + attentionField: root.samplerAttentionAxis === String(draft.axis) + ? root.samplerAttentionField : "" + attentionSerial: root.samplerAttentionAxis === String(draft.axis) + ? root.samplerAttentionSerial : 0 + enabled: !root.locked + onKindChangeRequested: (draft, kind) + => root.desktopController.requestSweepSamplerKindChange( + draft, kind) + onTextChangeRequested: (draft, field, text) + => root.desktopController.requestSweepSamplerTextChange( + draft, field, text) + onUnitChangeRequested: (draft, unit) + => root.desktopController.requestSweepSamplerUnitChange( + draft, unit) + sectionNumber: String(5 + index) + } + } + + Card { + id: outputCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "modelSweepOutputsCard" + sectionNumber: String(5 + samplerRepeater.count) + subtitle: qsTr("CSV and Parquet outputs use the complete public Sweep schema.") + title: qsTr("Dataset outputs") + + Repeater { + model: root.datasetDraft.outputFormats + + delegate: Item { + required property string display + required property int index + required property bool selected + required property string value + + Layout.fillWidth: true + implicitHeight: outputCheck.implicitHeight + + CheckBox { + id: outputCheck + + Accessible.name: qsTr("Emit %1 Sweep datasets").arg(parent.display) + anchors.left: parent.left + anchors.right: parent.right + checked: parent.selected + enabled: !root.locked + objectName: "modelSweepOutput-" + parent.value + onClicked: root.desktopController.requestSweepOutputSelection( + parent.value, checked) + text: parent.display + } + } + } + } + } + + Card { + id: comparisonCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "modelSweepComparisonsCard" + subtitle: qsTr( + "Committed order is serialized deterministically and participates in plan identity.") + title: qsTr("Comparison plots") + visible: root.documentActive + + RowLayout { + Layout.fillWidth: true + + Label { + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr("Shared format") + } + + AppComboBox { + Accessible.name: qsTr("Shared comparison plot format") + Layout.preferredWidth: 160 + currentIndex: indexForRoleValue(root.sweepDraft.comparisonFormat) + enabled: !root.locked && !root.sweepDraft.hasActiveComparisonEdit + model: ["png", "svg", "pdf"] + objectName: "modelSweepComparisonFormat" + onActivated: root.desktopController.requestSweepComparisonFormat(String( + currentValue)) + } + + Item { + Layout.fillWidth: true + } + + AppButton { + Accessible.description: qsTr("Open a temporary comparison plot editor") + enabled: !root.locked && !root.sweepDraft.hasActiveComparisonEdit + objectName: "modelSweepAddComparison" + onClicked: root.desktopController.requestSweepAddComparison() + text: qsTr("Add comparison") + tone: "primary" + } + } + + ListView { + id: comparisonList + + Accessible.name: qsTr("Committed comparison plots") + Layout.fillWidth: true + Layout.preferredHeight: Math.max(52, Math.min(260, contentHeight)) + boundsBehavior: Flickable.StopAtBounds + clip: true + interactive: contentHeight > height + model: root.sweepDraft.comparisonPlots + objectName: "modelSweepComparisonList" + pixelAligned: true + spacing: Theme.spacingTiny + + delegate: RowLayout { + id: comparisonRow + + required property string display + required property int index + + width: ListView.view.width + + Label { + Layout.fillWidth: true + color: Theme.text + font.family: Theme.sansFamily + font.pixelSize: 12 + text: comparisonRow.display + wrapMode: Text.Wrap + } + + AppButton { + Accessible.description: qsTr("Edit comparison %1").arg( + comparisonRow.display) + compact: true + enabled: !root.locked && !root.sweepDraft.hasActiveComparisonEdit + objectName: "modelSweepComparisonEdit-" + comparisonRow.index + onClicked: root.desktopController.requestSweepEditComparison( + comparisonRow.index) + text: qsTr("Edit") + } + + AppButton { + Accessible.description: qsTr("Move comparison %1 earlier").arg( + comparisonRow.display) + compact: true + enabled: !root.locked && !root.sweepDraft.hasActiveComparisonEdit + && comparisonRow.index > 0 + objectName: "modelSweepComparisonUp-" + comparisonRow.index + onClicked: root.desktopController.requestSweepMoveComparison( + comparisonRow.index, comparisonRow.index - 1) + text: qsTr("Up") + } + + AppButton { + Accessible.description: qsTr("Move comparison %1 later").arg( + comparisonRow.display) + compact: true + enabled: !root.locked && !root.sweepDraft.hasActiveComparisonEdit + && comparisonRow.index + 1 < comparisonList.count + objectName: "modelSweepComparisonDown-" + comparisonRow.index + onClicked: root.desktopController.requestSweepMoveComparison( + comparisonRow.index, comparisonRow.index + 1) + text: qsTr("Down") + } + + AppButton { + Accessible.description: qsTr("Remove comparison %1").arg( + comparisonRow.display) + compact: true + enabled: !root.locked && !root.sweepDraft.hasActiveComparisonEdit + objectName: "modelSweepComparisonRemove-" + comparisonRow.index + onClicked: root.desktopController.requestSweepRemoveComparison( + comparisonRow.index) + text: qsTr("Remove") + } + } + + Label { + anchors.centerIn: parent + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 12 + text: qsTr("No comparison plots configured") + visible: comparisonList.count === 0 + } + } + } + + Loader { + id: activeComparisonLoader + + Layout.fillWidth: true + active: root.sweepDraft.activeComparisonDraft !== null + objectName: "modelSweepActiveComparisonEditor" + sourceComponent: Component { + ComparisonPlotEditor { + attentionField: root.comparisonAttentionField + attentionRow: root.comparisonAttentionRow + attentionSerial: root.comparisonAttentionSerial + desktopController: root.desktopController + draft: root.sweepDraft.activeComparisonDraft + locked: root.locked + onCancelRequested: root.desktopController.requestSweepCancelComparison() + onCommitRequested: root.desktopController.requestSweepCommitComparison() + } + } + } + + WorkflowRunPanel { + Layout.fillWidth: true + workflowController: root.workflowController + workflowKind: "sweep" + onCancelRequested: workflow => root.desktopController.requestWorkflowCancel( + workflow) + onExecuteRequested: workflow => root.desktopController.requestWorkflowExecute( + workflow) + onForceStopRequested: workflow => root.desktopController.requestWorkflowForceStop( + workflow) + onInspectResultRequested: workflow + => root.desktopController.requestWorkflowInspectResult( + workflow) + onIssueFocusRequested: (section, field, row) => root.focusField(field, row) + onPlanRequested: workflow => root.desktopController.requestWorkflowPlan(workflow) + } + } + } + + Loader { + id: datasetShapeDialog + + active: root.dialogsEnabled + objectName: "modelSweepDatasetShapeDialog" + sourceComponent: Component { + DecisionDialog { + id: shapeDialog + + acceptText: qsTr("Replace sampler shape") + bodyText: qsTr( + "Changing this Dataset shape may replace incompatible sampler values. Other compatible Sweep selections remain unchanged.") + onAccepted: { + if (root.pendingDatasetChange === "mode") + root.desktopController.requestSweepModeChange(root.pendingDatasetValue, true); + else if (root.pendingDatasetChange === "coordinate") + root.desktopController.requestSweepCoordinateChange(root.pendingDatasetValue, + true); + root.pendingDatasetChange = ""; + root.pendingDatasetValue = ""; + } + onRejected: { + root.pendingDatasetChange = ""; + root.pendingDatasetValue = ""; + } + title: qsTr("Change Sweep dataset shape?") + + Connections { + function onShapeDialogRequested() { + shapeDialog.open(); + } + + target: root + } + } + } + } +} diff --git a/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml b/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml new file mode 100644 index 0000000..947af53 --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml @@ -0,0 +1,1226 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Carnopy + +Item { + id: root + + required property var configController + required property var desktopController + required property var inspectionController + required property var preparationDraft + required property var workflowController + property string actionMessage: "" + property string attentionField: "" + property int attentionRow: -1 + property int attentionSerial: 0 + property bool dialogsEnabled: true + property int expectedColumns: 1 + property string pendingCategoryField: "" + property string pendingCategoryMode: "" + property string scenarioAttentionField: "" + property int scenarioAttentionRow: -1 + property int scenarioAttentionSerial: 0 + readonly property bool documentActive: configController.documentKind === "preparation" + readonly property bool locked: !documentActive || !configController.canEdit + + signal arrayFormatSelectionRequested(string value, bool selected) + signal baselineModelSelectionRequested(string value, bool selected) + signal booleanFieldRequested(string field, bool value) + signal categoricalSelectionRequested(string field, bool selected) + signal categoryModeRequested(string field, string mode, bool confirmed) + signal categoryModeDialogRequested + signal explicitCategoriesRequested(string field, string values) + signal inspectSourceRequested + signal newPreparationRequested + signal roleSelectionRequested(string role, string value, bool selected) + signal scenarioAddRequested + signal scenarioCancelRequested + signal scenarioCommitRequested + signal scenarioEditRequested(int row) + signal scenarioMoveRequested(int source, int destination) + signal scenarioRemoveRequested(int row) + signal scenarioFieldChangeRequested(var draft, string field, string value) + signal scenarioKindChangeRequested(var draft, string kind, bool confirmed) + signal scenarioPartitionRequested(var draft, string partition, string ratio) + signal scenarioRemovePartitionRequested(var draft, string partition) + signal scenarioCategoricalHoldoutRequested(var draft, string partition, string values) + signal scenarioRangeHoldoutRequested(var draft, string partition, string minimum, + string maximum) + signal scenarioCoordinateHoldoutRequested(var draft, string partition, string field, + string minimum, string maximum) + signal scenarioRemoveHoldoutRequested(var draft, string partition) + signal scenarioStrataRequested(var draft, string fields) + signal scenarioNumericBinsRequested(var draft, string field, string boundaries) + signal scenarioRemoveNumericBinsRequested(var draft, string field) + signal scenarioTransformationAddRequested(var draft, string field, string methods) + signal scenarioTransformationRemoveRequested(var draft, int row) + signal scenarioTransformationMoveRequested(var draft, int source, int destination) + signal sourceBindRequested + signal sourceClearRequested(bool confirmed) + signal textFieldRequested(string field, string value) + signal workspaceRequested + + function reveal(item) { + if (item === null || item === undefined) + return; + const position = item.mapToItem(pageFlickable.contentItem, 0, 0); + const maximum = Math.max(0, pageFlickable.contentHeight - pageFlickable.height); + pageFlickable.contentY = Math.min(maximum, Math.max(0, position.y - 80)); + } + + function focusField(field, row) { + let target = numericCard; + if (field.indexOf("scenario") >= 0) { + const editor = activeScenarioLoader.item; + if (editor !== null) { + root.scenarioAttentionField = field; + root.scenarioAttentionRow = row; + root.scenarioAttentionSerial += 1; + root.reveal(editor); + return; + } + scenarioList.currentIndex = row; + target = scenariosCard; + } else if (field.indexOf("source_policy") >= 0) { + target = partialSweepCheck; + } else if (field.indexOf("source") >= 0) { + target = sourceCard; + } else if (field.indexOf("categorical") >= 0) { + target = categoricalCard; + } else if (field.indexOf("targets") >= 0) { + target = targetCard; + } else if (field.indexOf("auxiliary") >= 0) { + target = auxiliaryCard; + } else if (field.indexOf("derived") >= 0) { + target = derivedCard; + } else if (field.indexOf("outputs") >= 0 || field.indexOf("dependency") >= 0) { + target = outputsCard; + } else if (field.indexOf("baseline") >= 0) { + target = baselineCheck; + } else if (field.indexOf("matrix") >= 0 || field.indexOf("correlation") >= 0 || field.indexOf( + "spread") >= 0) { + target = matrixCheck; + } else if (field.indexOf("numeric") >= 0 || field.indexOf("features") >= 0) { + target = numericCard; + } + target.forceActiveFocus(); + root.reveal(target); + } + + onAttentionSerialChanged: Qt.callLater(function () { + root.focusField(root.attentionField, root.attentionRow); + }) + + Connections { + function onMessage(message) { + root.actionMessage = message; + } + + target: root.preparationDraft + } + + Flickable { + id: pageFlickable + + anchors.fill: parent + boundsBehavior: Flickable.StopAtBounds + clip: true + contentHeight: pageColumn.implicitHeight + 48 + contentWidth: width + flickableDirection: Flickable.VerticalFlick + objectName: "preparationPageFlickable" + pixelAligned: true + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ColumnLayout { + id: pageColumn + + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.top: parent.top + anchors.topMargin: 22 + spacing: Theme.spacingMedium + + RowLayout { + Layout.fillWidth: true + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + + Label { + Accessible.name: text + Layout.fillWidth: true + color: Theme.text + font.family: Theme.sansFamily + font.pixelSize: 23 + font.weight: Font.DemiBold + text: qsTr("ML Preparation configuration") + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 12 + text: qsTr( + "Define portable preparation policy while keeping the immutable source as explicit execution context.") + wrapMode: Text.Wrap + } + } + + StatusBadge { + label: !root.documentActive ? qsTr("No Preparation document") : ( + root.preparationDraft.hasActiveScenarioEdit + ? qsTr("Scenario edit open") : ( + root.preparationDraft.locallyValid + ? qsTr("Locally complete") : qsTr( + "Needs attention"))) + objectName: "preparationLocalState" + tone: !root.documentActive ? "neutral" : ( + root.preparationDraft.hasActiveScenarioEdit + ? "warning" : ( + root.preparationDraft.locallyValid + ? "success" : "danger")) + } + } + + Card { + Layout.fillWidth: true + objectName: "preparationNoDocumentCard" + subtitle: qsTr( + "Create or open an ML Preparation configuration. A retained finalized result remains inspectable below.") + title: qsTr("No ML Preparation configuration is active") + visible: !root.documentActive + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + AppButton { + objectName: "preparationCreateDocumentButton" + onClicked: root.newPreparationRequested() + text: qsTr("New ML Preparation") + tone: "primary" + visible: root.workflowController.hasBoundSource + } + + AppButton { + objectName: "preparationChooseSourceButton" + onClicked: root.inspectSourceRequested() + text: qsTr("Choose source in Inspect") + tone: "primary" + visible: !root.workflowController.hasBoundSource + } + + AppButton { + objectName: "preparationOpenWorkspaceButton" + onClicked: root.workspaceRequested() + text: qsTr("Open Workspace") + tone: "quiet" + } + } + } + + BlockingBanner { + Layout.fillWidth: true + field: root.preparationDraft.firstInvalidField + message: root.preparationDraft.issue + row: root.preparationDraft.firstInvalidRow + section: "preparation" + title: qsTr("Preparation configuration needs attention") + visible: root.documentActive && !root.preparationDraft.locallyValid + onActionRequested: (section, field, row) => root.focusField(field, row) + } + + ValidationIssue { + Layout.fillWidth: true + field: root.preparationDraft.firstInvalidField + issue: root.actionMessage + objectName: "preparationActionMessage" + } + + Card { + id: sourceCard + + Layout.fillWidth: true + activeFocusOnTab: true + meta: root.workflowController.hasBoundSource ? qsTr("Bound") : qsTr("Required") + metaColor: root.workflowController.hasBoundSource ? Theme.success : Theme.warning + objectName: "preparationBoundSourceCard" + sectionNumber: "1" + subtitle: root.workflowController.hasBoundSource + ? root.workflowController.boundSourcePath : qsTr( + "Inspect an eligible finalized Dataset or Model Sweep, then bind that exact verified revision explicitly.") + title: qsTr("Explicit source context") + visible: root.documentActive + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + StatusBadge { + label: root.workflowController.boundSourceRefreshAvailable ? qsTr( + "Refresh available") : + (root.workflowController.hasBoundSource + ? root.workflowController.boundSourceKind : + qsTr("Unbound")) + objectName: "preparationBoundSourceState" + tone: root.workflowController.boundSourceRefreshAvailable ? "warning" : ( + root.workflowController.hasBoundSource + ? "success" : + "warning") + } + + Label { + color: Theme.textMuted + elide: Text.ElideMiddle + font.family: Theme.monoFamily + font.pixelSize: 10 + text: root.workflowController.hasBoundSource + ? root.workflowController.boundSourceRevision : "" + visible: text.length > 0 + } + } + + Label { + Layout.fillWidth: true + color: Theme.warning + font.family: Theme.sansFamily + font.pixelSize: 11 + text: qsTr( + "A refreshed inspection is available. Rebinding is explicit and will stale the current plan without changing YAML.") + visible: root.workflowController.boundSourceRefreshAvailable + wrapMode: Text.Wrap + } + + Label { + Layout.fillWidth: true + color: Theme.warning + font.family: Theme.sansFamily + font.pixelSize: 11 + text: root.workflowController.sourceBindingIssue.length > 0 + ? root.workflowController.sourceBindingIssue : + root.preparationDraft.sourceIssue + visible: text.length > 0 + wrapMode: Text.Wrap + } + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + AppButton { + enabled: root.workflowController.inspectedSourceAvailable + && root.inspectionController.canInspect && + !root.workflowController.operationActive + objectName: "preparationUseInspectedSource" + onClicked: root.sourceBindRequested() + text: root.workflowController.boundSourceRefreshAvailable ? qsTr( + "Use refreshed source") : + qsTr("Use inspected source") + visible: root.inspectionController.preparationEligible + } + + AppButton { + objectName: "preparationChangeSource" + onClicked: root.inspectSourceRequested() + text: root.workflowController.hasBoundSource ? qsTr( + "Inspect or change source") : + qsTr("Choose source in Inspect") + tone: root.workflowController.hasBoundSource ? "quiet" : "primary" + } + + AppButton { + enabled: root.workflowController.hasBoundSource && + !root.workflowController.operationActive + objectName: "preparationClearSource" + onClicked: root.sourceClearRequested(false) + text: qsTr("Clear source") + tone: "danger" + visible: root.workflowController.hasBoundSource + } + } + } + + ResponsiveCardGrid { + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + maximumColumns: Math.min(3, root.expectedColumns) + minimumCardWidth: 300 + objectName: "preparationRolesGrid" + uniformHeights: false + visible: root.documentActive + + Card { + id: numericCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "preparationNumericFeaturesCard" + sectionNumber: "2" + subtitle: qsTr( + "Unavailable imported selections remain visible and blocking until resolved explicitly.") + title: qsTr("Numeric features") + + Repeater { + model: root.preparationDraft.numericChoices + + delegate: Item { + required property bool compatible + required property string display + required property int index + required property string issue + required property bool selected + required property string value + + implicitHeight: numericCheck.implicitHeight + implicitWidth: numericCheck.implicitWidth + + CheckBox { + id: numericCheck + + Accessible.description: parent.issue + Accessible.name: qsTr("Use %1 as a numeric feature").arg( + parent.display) + checked: parent.selected + enabled: !root.locked && (parent.compatible || parent.selected) + objectName: "preparationNumeric-" + parent.value + onClicked: root.roleSelectionRequested("numeric", parent.value, + checked) + text: parent.display + + ToolTip.text: parent.issue + ToolTip.visible: hovered && parent.issue.length > 0 + } + } + } + } + + Card { + id: derivedCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "preparationDerivedFeaturesCard" + sectionNumber: "3" + subtitle: qsTr( + "Critical-property prerequisites come from the bound worker profile.") + title: qsTr("Derived features") + + Repeater { + model: root.preparationDraft.derivedChoices + + delegate: Item { + required property bool compatible + required property string display + required property int index + required property string issue + required property bool selected + required property string value + + implicitHeight: derivedCheck.implicitHeight + implicitWidth: derivedCheck.implicitWidth + + CheckBox { + id: derivedCheck + + Accessible.description: parent.issue + Accessible.name: qsTr("Use %1 as a derived feature").arg( + parent.display) + checked: parent.selected + enabled: !root.locked && (parent.compatible || parent.selected) + objectName: "preparationDerived-" + parent.value + onClicked: root.roleSelectionRequested("derived", parent.value, + checked) + text: parent.display + + ToolTip.text: parent.issue + ToolTip.visible: hovered && parent.issue.length > 0 + } + } + } + } + + Card { + id: targetCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "preparationTargetsCard" + sectionNumber: "4" + subtitle: qsTr( + "At least one target is required by the unchanged public schema.") + title: qsTr("Targets") + + Repeater { + model: root.preparationDraft.targetChoices + + delegate: Item { + required property bool compatible + required property string display + required property int index + required property string issue + required property bool selected + required property string value + + implicitHeight: targetCheck.implicitHeight + implicitWidth: targetCheck.implicitWidth + + CheckBox { + id: targetCheck + + Accessible.description: parent.issue + Accessible.name: qsTr("Use %1 as a target").arg(parent.display) + checked: parent.selected + enabled: !root.locked && (parent.compatible || parent.selected) + objectName: "preparationTarget-" + parent.value + onClicked: root.roleSelectionRequested("target", parent.value, + checked) + text: parent.display + + ToolTip.text: parent.issue + ToolTip.visible: hovered && parent.issue.length > 0 + } + } + } + } + + Card { + id: auxiliaryCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "preparationAuxiliaryCard" + sectionNumber: "5" + subtitle: qsTr( + "Auxiliary fields retain source context without becoming model features.") + title: qsTr("Auxiliary fields") + + Repeater { + model: root.preparationDraft.auxiliaryChoices + + delegate: Item { + required property bool compatible + required property string display + required property int index + required property string issue + required property bool selected + required property string value + + implicitHeight: auxiliaryCheck.implicitHeight + implicitWidth: auxiliaryCheck.implicitWidth + + CheckBox { + id: auxiliaryCheck + + Accessible.description: parent.issue + Accessible.name: qsTr("Use %1 as an auxiliary field").arg( + parent.display) + checked: parent.selected + enabled: !root.locked && (parent.compatible || parent.selected) + objectName: "preparationAuxiliary-" + parent.value + onClicked: root.roleSelectionRequested("auxiliary", parent.value, + checked) + text: parent.display + + ToolTip.text: parent.issue + ToolTip.visible: hovered && parent.issue.length > 0 + } + } + } + } + + Card { + id: categoricalCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "preparationCategoricalCard" + sectionNumber: "6" + subtitle: qsTr( + "Observed values come from the bound profile; explicit category order is serialized exactly.") + title: qsTr("Categorical features") + + Repeater { + id: categoricalRepeater + + model: root.preparationDraft.categoricalChoices + + delegate: ColumnLayout { + id: categoryRow + + required property bool compatible + required property string display + required property int index + required property string issue + required property bool selected + required property string value + + Layout.fillWidth: true + objectName: "preparationCategoricalRow-" + categoryRow.index + + CheckBox { + id: categoryCheck + + Accessible.description: categoryRow.issue + Accessible.name: qsTr("Use %1 as a categorical feature").arg( + categoryRow.display) + checked: categoryRow.selected + enabled: !root.locked && (categoryRow.compatible + || categoryRow.selected) + objectName: "preparationCategorical-" + categoryRow.value + onClicked: root.categoricalSelectionRequested(categoryRow.value, + checked) + text: categoryRow.display + + ToolTip.text: categoryRow.issue + ToolTip.visible: hovered && categoryRow.issue.length > 0 + } + + AppComboBox { + id: categoryMode + + Accessible.name: qsTr("Category source for %1").arg( + categoryRow.display) + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.preparationDraft.category_mode( + categoryRow.value)) + enabled: !root.locked && categoryRow.selected + model: ["observed", "explicit"] + objectName: "preparationCategoryMode-" + categoryRow.value + onActivated: { + const selectedMode = String(currentValue); + if (selectedMode === root.preparationDraft.category_mode( + categoryRow.value)) + return; + if (selectedMode === "observed" + && root.preparationDraft.explicit_categories_text( + categoryRow.value).length > 0) { + root.pendingCategoryField = categoryRow.value; + root.pendingCategoryMode = selectedMode; + root.categoryModeDialogRequested(); + } else { + root.categoryModeRequested(categoryRow.value, selectedMode, + false); + } + } + visible: categoryRow.selected + } + + TextField { + Accessible.name: qsTr("Explicit categories for %1").arg( + categoryRow.display) + Layout.fillWidth: true + enabled: !root.locked && categoryRow.selected + objectName: "preparationExplicitCategories-" + categoryRow.value + onEditingFinished: root.explicitCategoriesRequested( + categoryRow.value, text) + placeholderText: qsTr("Comma-separated categories") + selectByMouse: true + text: root.preparationDraft.explicit_categories_text( + categoryRow.value) + visible: categoryRow.selected && categoryMode.currentValue + === "explicit" + } + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 10 + text: qsTr("Observed: %1").arg( + root.preparationDraft.observed_categories( + categoryRow.value).join(", ")) + visible: categoryRow.selected && categoryMode.currentValue + === "observed" + wrapMode: Text.Wrap + } + } + } + } + } + + Card { + id: scenariosCard + + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "preparationScenariosCard" + subtitle: qsTr( + "All eight public scenario kinds are available. Committed order is exact and deterministic.") + title: qsTr("Scenarios") + visible: root.documentActive + + RowLayout { + Layout.fillWidth: true + + CheckBox { + id: partialSweepCheck + + Accessible.description: qsTr( + "This policy never changes the bound source or Preparation YAML roles") + Accessible.name: qsTr("Allow an eligible partial Model Sweep source") + checked: root.preparationDraft.allowPartialSweep + enabled: !root.locked && root.workflowController.boundSourceKind + === "model_sweep" + objectName: "preparationAllowPartialSweep" + onClicked: root.booleanFieldRequested("allow_partial_sweep", checked) + text: qsTr("Allow eligible partial Sweep sources") + } + + Item { + Layout.fillWidth: true + } + + AppButton { + Accessible.description: qsTr("Open a temporary Preparation scenario editor") + enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit + objectName: "preparationAddScenario" + onClicked: root.scenarioAddRequested() + text: qsTr("Add scenario") + tone: "primary" + } + } + + ListView { + id: scenarioList + + Accessible.name: qsTr("Committed Preparation scenarios") + Layout.fillWidth: true + Layout.preferredHeight: Math.max(52, Math.min(280, contentHeight)) + boundsBehavior: Flickable.StopAtBounds + clip: true + interactive: contentHeight > height + model: root.preparationDraft.scenarios + objectName: "preparationScenarioList" + pixelAligned: true + spacing: Theme.spacingTiny + + delegate: RowLayout { + id: scenarioRow + + required property int index + required property string name + required property string summary + + width: ListView.view.width + + Label { + Layout.fillWidth: true + color: Theme.text + font.family: Theme.sansFamily + font.pixelSize: 12 + text: scenarioRow.name + " · " + scenarioRow.summary + wrapMode: Text.Wrap + } + + AppButton { + Accessible.description: qsTr("Edit scenario %1").arg(scenarioRow.name) + compact: true + enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit + objectName: "preparationScenarioEdit-" + scenarioRow.index + onClicked: root.scenarioEditRequested(scenarioRow.index) + text: qsTr("Edit") + } + + AppButton { + Accessible.description: qsTr("Move scenario %1 earlier").arg( + scenarioRow.name) + compact: true + enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit + && scenarioRow.index > 0 + objectName: "preparationScenarioUp-" + scenarioRow.index + onClicked: root.scenarioMoveRequested(scenarioRow.index, + scenarioRow.index - 1) + text: qsTr("Up") + } + + AppButton { + Accessible.description: qsTr("Move scenario %1 later").arg( + scenarioRow.name) + compact: true + enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit + && scenarioRow.index + 1 < scenarioList.count + objectName: "preparationScenarioDown-" + scenarioRow.index + onClicked: root.scenarioMoveRequested(scenarioRow.index, + scenarioRow.index + 1) + text: qsTr("Down") + } + + AppButton { + Accessible.description: qsTr("Remove scenario %1").arg(scenarioRow.name) + compact: true + enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit + objectName: "preparationScenarioRemove-" + scenarioRow.index + onClicked: root.scenarioRemoveRequested(scenarioRow.index) + text: qsTr("Remove") + } + } + + Label { + anchors.centerIn: parent + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 12 + text: qsTr("No scenarios configured") + visible: scenarioList.count === 0 + } + } + } + + Loader { + id: activeScenarioLoader + + Layout.fillWidth: true + active: root.preparationDraft.activeScenarioDraft !== null + objectName: "preparationActiveScenarioEditor" + sourceComponent: Component { + PreparationScenarioEditor { + attentionField: root.scenarioAttentionField + attentionRow: root.scenarioAttentionRow + attentionSerial: root.scenarioAttentionSerial + desktopController: root.desktopController + dialogsEnabled: root.dialogsEnabled + draft: root.preparationDraft.activeScenarioDraft + locked: root.locked + onCancelRequested: root.scenarioCancelRequested() + onCommitRequested: root.scenarioCommitRequested() + onScenarioCategoricalHoldoutRequested: (draft, partition, values) + => root.scenarioCategoricalHoldoutRequested( + draft, partition, values) + onScenarioCoordinateHoldoutRequested: (draft, partition, field, minimum, + maximum) + => root.scenarioCoordinateHoldoutRequested( + draft, partition, field, + minimum, maximum) + onScenarioFieldChangeRequested: (draft, field, value) + => root.scenarioFieldChangeRequested(draft, + field, value) + onScenarioKindChangeRequested: (draft, kind, confirmed) + => root.scenarioKindChangeRequested(draft, + kind, confirmed) + onScenarioNumericBinsRequested: (draft, field, boundaries) + => root.scenarioNumericBinsRequested(draft, + field, boundaries) + onScenarioPartitionRequested: (draft, partition, ratio) + => root.scenarioPartitionRequested(draft, + partition, + ratio) + onScenarioRangeHoldoutRequested: (draft, partition, minimum, maximum) + => root.scenarioRangeHoldoutRequested(draft, + partition, + minimum, maximum) + onScenarioRemoveHoldoutRequested: (draft, partition) + => root.scenarioRemoveHoldoutRequested( + draft, partition) + onScenarioRemoveNumericBinsRequested: (draft, field) + => root.scenarioRemoveNumericBinsRequested( + draft, field) + onScenarioRemovePartitionRequested: (draft, partition) + => root.scenarioRemovePartitionRequested( + draft, partition) + onScenarioStrataRequested: (draft, fields) => root.scenarioStrataRequested( + draft, fields) + onScenarioTransformationAddRequested: (draft, field, methods) + => root.scenarioTransformationAddRequested( + draft, field, methods) + onScenarioTransformationMoveRequested: (draft, source, destination) + => root.scenarioTransformationMoveRequested( + draft, source, destination) + onScenarioTransformationRemoveRequested: (draft, row) + => root.scenarioTransformationRemoveRequested( + draft, row) + } + } + } + + ResponsiveCardGrid { + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + maximumColumns: Math.min(2, root.expectedColumns) + minimumCardWidth: 360 + objectName: "preparationOutputQualityGrid" + uniformHeights: false + visible: root.documentActive + + Card { + id: outputsCard + + Layout.alignment: Qt.AlignTop + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "preparationOutputsCard" + subtitle: qsTr( + "Parquet is always emitted. Optional array requests remain visible when the current installation cannot execute them.") + title: qsTr("Outputs") + + CheckBox { + Accessible.description: qsTr( + "Parquet is the authoritative Preparation output") + Accessible.name: qsTr("Emit Parquet Preparation table") + checked: true + enabled: false + objectName: "preparationParquetOutput" + text: qsTr("Parquet table") + } + + CheckBox { + Accessible.name: qsTr("Emit array artifacts") + checked: root.preparationDraft.arrayOutputsEnabled + enabled: !root.locked + objectName: "preparationArrayOutputs" + onClicked: root.booleanFieldRequested("array_outputs", checked) + text: qsTr("Array artifacts") + } + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + visible: root.preparationDraft.arrayOutputsEnabled + + Repeater { + model: root.preparationDraft.arrayFormatChoices + + delegate: Item { + required property bool compatible + required property string display + required property string issue + required property bool selected + required property string value + + implicitHeight: arrayFormatCheck.implicitHeight + implicitWidth: arrayFormatCheck.implicitWidth + + CheckBox { + id: arrayFormatCheck + + Accessible.description: parent.issue + Accessible.name: qsTr("Emit %1 arrays").arg(parent.display) + checked: parent.selected + enabled: !root.locked && (parent.compatible || parent.selected) + objectName: "preparationArrayFormat-" + parent.value + onClicked: root.arrayFormatSelectionRequested(parent.value, + checked) + text: parent.display + + ToolTip.text: parent.issue + ToolTip.visible: hovered && parent.issue.length > 0 + } + } + } + } + + AppComboBox { + Accessible.name: qsTr("Array data type") + Layout.fillWidth: true + currentIndex: indexForRoleValue(root.preparationDraft.arrayDtype) + enabled: !root.locked && root.preparationDraft.arrayOutputsEnabled + model: ["float32", "float64"] + objectName: "preparationArrayDtype" + onActivated: root.textFieldRequested("array_dtype", String(currentValue)) + visible: root.preparationDraft.arrayOutputsEnabled + } + + CheckBox { + Accessible.name: qsTr("Include auxiliary fields in arrays") + checked: root.preparationDraft.includeAuxiliary + enabled: !root.locked && root.preparationDraft.arrayOutputsEnabled + objectName: "preparationArrayIncludeAuxiliary" + onClicked: root.booleanFieldRequested("include_auxiliary", checked) + text: qsTr("Include auxiliary fields in arrays") + visible: root.preparationDraft.arrayOutputsEnabled + } + } + + Card { + Layout.alignment: Qt.AlignTop + Layout.fillWidth: true + activeFocusOnTab: true + objectName: "preparationQualityCard" + subtitle: qsTr( + "Diagnostics do not alter prepared rows. Optional baseline dependencies are explicit.") + title: qsTr("Quality diagnostics") + + CheckBox { + id: matrixCheck + + Accessible.name: qsTr("Enable matrix diagnostics") + checked: root.preparationDraft.matrixDiagnosticsEnabled + enabled: !root.locked + objectName: "preparationMatrixDiagnostics" + onClicked: root.booleanFieldRequested("matrix_diagnostics", checked) + text: qsTr("Matrix diagnostics") + } + + GridLayout { + Layout.fillWidth: true + columns: width >= 520 ? 2 : 1 + objectName: "preparationMatrixSettingsGrid" + visible: root.preparationDraft.matrixDiagnosticsEnabled + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingTiny + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + objectName: "preparationCorrelationThresholdLabel" + text: qsTr("Correlation threshold (absolute r)") + wrapMode: Text.Wrap + } + + TextField { + Accessible.name: qsTr("Correlation threshold") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationCorrelationThreshold" + onEditingFinished: root.textFieldRequested("correlation_threshold", + text) + selectByMouse: true + text: root.preparationDraft.correlationThreshold + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingTiny + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + objectName: "preparationNearConstantSpreadLabel" + text: qsTr("Near-constant relative spread") + wrapMode: Text.Wrap + } + + TextField { + Accessible.name: qsTr("Near-constant relative spread") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationNearConstantSpread" + onEditingFinished: root.textFieldRequested( + "near_constant_relative_spread", text) + selectByMouse: true + text: root.preparationDraft.nearConstantRelativeSpread + } + } + } + + CheckBox { + id: baselineCheck + + Accessible.description: root.preparationDraft.baselineDiagnosticsAvailable + ? "" : root.preparationDraft.baselineDiagnosticsGuidance + Accessible.name: qsTr("Enable baseline diagnostics") + checked: root.preparationDraft.baselineDiagnosticsEnabled + enabled: !root.locked && ( + root.preparationDraft.baselineDiagnosticsAvailable + || root.preparationDraft.baselineDiagnosticsEnabled) + objectName: "preparationBaselineDiagnostics" + onClicked: root.booleanFieldRequested("baseline_diagnostics", checked) + text: qsTr("Baseline diagnostics") + } + + Label { + Accessible.name: text + Layout.fillWidth: true + color: Theme.warning + font.family: Theme.sansFamily + font.pixelSize: 11 + objectName: "preparationBaselineDependencyGuidance" + text: root.preparationDraft.baselineDiagnosticsGuidance + visible: text.length > 0 + wrapMode: Text.Wrap + } + + Flow { + Layout.fillWidth: true + spacing: Theme.spacingSmall + visible: root.preparationDraft.baselineDiagnosticsEnabled + + Repeater { + model: root.preparationDraft.baselineModelChoices + + delegate: Item { + required property bool compatible + required property string display + required property string issue + required property bool selected + required property string value + + implicitHeight: baselineModelCheck.implicitHeight + implicitWidth: baselineModelCheck.implicitWidth + + CheckBox { + id: baselineModelCheck + + Accessible.description: parent.issue + Accessible.name: qsTr("Run %1 baseline").arg(parent.display) + checked: parent.selected + enabled: !root.locked && (parent.compatible || parent.selected) + objectName: "preparationBaselineModel-" + parent.value + onClicked: root.baselineModelSelectionRequested(parent.value, + checked) + text: parent.display + + ToolTip.text: parent.issue + ToolTip.visible: hovered && parent.issue.length > 0 + } + } + } + } + + GridLayout { + Layout.fillWidth: true + columns: width >= 620 ? 3 : 1 + objectName: "preparationBaselineSettingsGrid" + visible: root.preparationDraft.baselineDiagnosticsEnabled + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingTiny + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + objectName: "preparationBaselineSeedLabel" + text: qsTr("Baseline random seed") + wrapMode: Text.Wrap + } + + TextField { + Accessible.name: qsTr("Baseline random seed") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationBaselineSeed" + onEditingFinished: root.textFieldRequested("baseline_random_seed", + text) + selectByMouse: true + text: root.preparationDraft.baselineRandomSeed + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingTiny + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + objectName: "preparationRidgeAlphaLabel" + text: qsTr("Ridge alpha") + wrapMode: Text.Wrap + } + + TextField { + Accessible.name: qsTr("Ridge alpha") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationRidgeAlpha" + onEditingFinished: root.textFieldRequested("ridge_alpha", text) + selectByMouse: true + text: root.preparationDraft.ridgeAlpha + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingTiny + + Label { + Layout.fillWidth: true + color: Theme.textMuted + font.family: Theme.sansFamily + font.pixelSize: 11 + objectName: "preparationHistogramIterationsLabel" + text: qsTr("Histogram maximum iterations") + wrapMode: Text.Wrap + } + + TextField { + Accessible.name: qsTr("Histogram maximum iterations") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationHistogramIterations" + onEditingFinished: root.textFieldRequested( + "histogram_max_iterations", text) + selectByMouse: true + text: root.preparationDraft.histogramMaxIterations + } + } + } + } + } + + WorkflowRunPanel { + Layout.fillWidth: true + workflowController: root.workflowController + workflowKind: "preparation" + onCancelRequested: workflow => root.desktopController.requestWorkflowCancel( + workflow) + onExecuteRequested: workflow => root.desktopController.requestWorkflowExecute( + workflow) + onForceStopRequested: workflow => root.desktopController.requestWorkflowForceStop( + workflow) + onInspectResultRequested: workflow + => root.desktopController.requestWorkflowInspectResult( + workflow) + onIssueFocusRequested: (section, field, row) => root.focusField(field, row) + onPlanRequested: workflow => root.desktopController.requestWorkflowPlan(workflow) + } + } + } + + Loader { + active: root.dialogsEnabled + objectName: "preparationCategoryModeDialogLoader" + sourceComponent: Component { + DecisionDialog { + id: categoryModeDialog + + acceptText: qsTr("Use observed categories") + bodyText: qsTr( + "Using source-observed categories discards the temporary explicit category list for this field.") + objectName: "preparationCategoryModeDialog" + onAccepted: { + root.categoryModeRequested(root.pendingCategoryField, root.pendingCategoryMode, + true); + root.pendingCategoryField = ""; + root.pendingCategoryMode = ""; + } + onRejected: { + root.pendingCategoryField = ""; + root.pendingCategoryMode = ""; + } + title: qsTr("Replace explicit categories?") + + Connections { + function onCategoryModeDialogRequested() { + categoryModeDialog.open(); + } + + target: root + } + } + } + } +} diff --git a/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml b/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml index 02fcbce..4f707a6 100644 --- a/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml @@ -20,6 +20,8 @@ Item { required property url importFolder property int expectedColumns: 1 readonly property bool controllerAvailable: desktopController !== null + readonly property bool configurationActionsVisible: workspaceState === "landing" + || workspaceState === "editing" readonly property string workspaceState: controllerAvailable ? desktopController.workspaceState : "unavailable" property bool initializeSelectionAccepted: false @@ -34,8 +36,10 @@ Item { signal createWorkspaceRequested(string parentPath, string childName) signal initializeWorkspaceRequested(string path) signal openWorkspaceRequested(string path) - signal importDatasetRequested(string path) + signal importConfigurationRequested(string path) signal newDatasetRequested(string mode) + signal newPreparationRequested + signal newSweepRequested property bool importSelectionAccepted: false property string importSelectionPath: "" @@ -90,7 +94,7 @@ Item { if (window !== null) window.requestActivate(); if (root.controllerAvailable) - root.importDatasetRequested(path); + root.importConfigurationRequested(path); }); } @@ -323,7 +327,7 @@ Item { maximumColumns: 3 minimumCardWidth: 300 objectName: "newDatasetModeGrid" - visible: root.workspaceState === "landing" + visible: root.configurationActionsVisible Repeater { model: root.controllerAvailable @@ -345,7 +349,7 @@ Item { AppButton { enabled: root.controllerAvailable - && root.desktopController.datasetConfigController.canCreate + && root.desktopController.configurationController.canCreate objectName: "newDatasetButton-" + value onClicked: root.newDatasetRequested(value) text: qsTr("New Dataset") @@ -357,14 +361,67 @@ Item { Card { Layout.fillWidth: true + objectName: "newModelSweepCard" subtitle: qsTr( - "Choose YAML from configs/. Generated datasets are stored in outputs/ and rendered plots in figures/. External YAML remains importable.") + "Compare two or more CoolProp models over one reproducible dataset specification with worker-verified planning and execution.") + title: qsTr("Model Sweep") + visible: root.configurationActionsVisible + + AppButton { + Accessible.description: qsTr( + "Create a new structured Model Sweep configuration") + enabled: root.controllerAvailable + && root.desktopController.configurationController.canCreate + iconName: "git-compare-arrows" + objectName: "newModelSweepButton" + onClicked: root.newSweepRequested() + text: qsTr("New Model Sweep") + tone: "primary" + } + } + + Card { + Layout.fillWidth: true + meta: root.controllerAvailable + && root.desktopController.preparationWorkflowController.hasBoundSource ? qsTr( + "Source bound") : + qsTr("Source required") + metaColor: root.controllerAvailable + && root.desktopController.preparationWorkflowController.hasBoundSource + ? Theme.success : Theme.warning + objectName: "newPreparationCard" + subtitle: root.controllerAvailable + && root.desktopController.preparationWorkflowController.hasBoundSource + ? qsTr("Create a portable structured Preparation configuration for the explicitly bound verified source.") : + qsTr("Inspect an eligible finalized Dataset or Model Sweep and choose Use for ML Preparation first.") + title: qsTr("ML Preparation") + visible: root.configurationActionsVisible + + AppButton { + Accessible.description: qsTr( + "Create a new structured ML Preparation configuration or choose its required source") + enabled: root.controllerAvailable + && root.desktopController.configurationController.canCreate + iconName: "flask-conical" + objectName: "newPreparationButton" + onClicked: root.newPreparationRequested() + text: root.controllerAvailable + && root.desktopController.preparationWorkflowController.hasBoundSource + ? qsTr("New ML Preparation") : qsTr("Choose source in Inspect") + tone: "primary" + } + } + + Card { + Layout.fillWidth: true + subtitle: qsTr( + "Choose Dataset, Model Sweep, or Preparation YAML from configs/. The document_type discriminator selects the exact public schema. External YAML remains importable.") title: qsTr("Open or Import Configuration") - visible: root.workspaceState === "landing" + visible: root.configurationActionsVisible AppButton { enabled: root.controllerAvailable - && root.desktopController.datasetConfigController.canImport + && root.desktopController.configurationController.canImport objectName: "importDatasetButton" onClicked: root.openImportDialog() text: qsTr("Choose YAML") diff --git a/src/carnopy/app/qml/Carnopy/qmldir b/src/carnopy/app/qml/Carnopy/qmldir index f628523..92c2d8e 100644 --- a/src/carnopy/app/qml/Carnopy/qmldir +++ b/src/carnopy/app/qml/Carnopy/qmldir @@ -8,6 +8,9 @@ AppIcon 1.0 components/AppIcon.qml BlockingBanner 1.0 components/BlockingBanner.qml Card 1.0 components/Card.qml CommandBar 1.0 components/CommandBar.qml +ComparisonPlotEditor 1.0 components/ComparisonPlotEditor.qml +PreparationAuditView 1.0 components/PreparationAuditView.qml +PreparationScenarioEditor 1.0 components/PreparationScenarioEditor.qml ContextInspector 1.0 components/ContextInspector.qml RunContextInspector 1.0 components/RunContextInspector.qml InspectionContextInspector 1.0 components/InspectionContextInspector.qml @@ -28,6 +31,8 @@ ChoiceList 1.0 components/ChoiceList.qml SearchableChoiceList 1.0 components/SearchableChoiceList.qml SamplerEditor 1.0 components/SamplerEditor.qml ValidationIssue 1.0 components/ValidationIssue.qml +WorkflowContextInspector 1.0 components/WorkflowContextInspector.qml +WorkflowRunPanel 1.0 components/WorkflowRunPanel.qml EmptyStatePage 1.0 pages/EmptyStatePage.qml HelpPage 1.0 pages/HelpPage.qml SettingsPage 1.0 pages/SettingsPage.qml @@ -38,3 +43,5 @@ YamlPreviewPage 1.0 pages/YamlPreviewPage.qml RunPage 1.0 pages/RunPage.qml InspectPage 1.0 pages/InspectPage.qml ActivityPage 1.0 pages/ActivityPage.qml +ModelSweepPage 1.0 pages/ModelSweepPage.qml +PreparationPage 1.0 pages/PreparationPage.qml diff --git a/src/carnopy/app/qml_resources.py b/src/carnopy/app/qml_resources.py index cda2a9b..7ee4be7 100644 --- a/src/carnopy/app/qml_resources.py +++ b/src/carnopy/app/qml_resources.py @@ -34,6 +34,9 @@ "qml/Carnopy/components/ChoiceList.qml", "qml/Carnopy/components/SearchableChoiceList.qml", "qml/Carnopy/components/CommandBar.qml", + "qml/Carnopy/components/ComparisonPlotEditor.qml", + "qml/Carnopy/components/PreparationAuditView.qml", + "qml/Carnopy/components/PreparationScenarioEditor.qml", "qml/Carnopy/components/ContextInspector.qml", "qml/Carnopy/components/InspectionContextInspector.qml", "qml/Carnopy/components/ActivityContextInspector.qml", @@ -49,11 +52,15 @@ "qml/Carnopy/components/StatusBadge.qml", "qml/Carnopy/components/ToastHost.qml", "qml/Carnopy/components/ValidationIssue.qml", + "qml/Carnopy/components/WorkflowContextInspector.qml", + "qml/Carnopy/components/WorkflowRunPanel.qml", "qml/Carnopy/components/WorkspaceOperationDialog.qml", "qml/Carnopy/pages/EmptyStatePage.qml", "qml/Carnopy/pages/DatasetPage.qml", "qml/Carnopy/pages/HelpPage.qml", "qml/Carnopy/pages/InspectPage.qml", + "qml/Carnopy/pages/ModelSweepPage.qml", + "qml/Carnopy/pages/PreparationPage.qml", "qml/Carnopy/pages/ActivityPage.qml", "qml/Carnopy/pages/SettingsPage.qml", "qml/Carnopy/pages/WorkspacePage.qml", diff --git a/src/carnopy/app/qml_runtime.py b/src/carnopy/app/qml_runtime.py index 38f6dea..2e85d61 100644 --- a/src/carnopy/app/qml_runtime.py +++ b/src/carnopy/app/qml_runtime.py @@ -488,7 +488,124 @@ def _connect_qml_facade(self, root: QObject) -> None: ("workspaceCommitRequested", self.controller.request_commit_workspace_operation), ("workspaceCancelRequested", self.controller.request_cancel_workspace_operation), ("datasetNewRequested", self.controller.request_new_dataset), - ("datasetImportRequested", self.controller.request_import_dataset), + ("sweepNewRequested", self.controller.request_new_sweep), + ("preparationNewRequested", self.controller.request_new_preparation), + ( + "preparationArrayFormatSelectionRequested", + self.controller.request_preparation_array_format_selection, + ), + ( + "preparationBaselineModelSelectionRequested", + self.controller.request_preparation_baseline_model_selection, + ), + ( + "preparationBooleanFieldRequested", + self.controller.request_preparation_boolean_field, + ), + ( + "preparationCategoricalSelectionRequested", + self.controller.request_preparation_categorical_selection, + ), + ( + "preparationCategoryModeRequested", + self.controller.request_preparation_category_mode, + ), + ( + "preparationExplicitCategoriesRequested", + self.controller.request_preparation_explicit_categories, + ), + ( + "preparationRoleSelectionRequested", + self.controller.request_preparation_role_selection, + ), + ( + "preparationScenarioAddRequested", + self.controller.request_preparation_add_scenario, + ), + ( + "preparationScenarioEditRequested", + self.controller.request_preparation_edit_scenario, + ), + ( + "preparationScenarioCommitRequested", + self.controller.request_preparation_commit_scenario, + ), + ( + "preparationScenarioCancelRequested", + self.controller.request_preparation_cancel_scenario, + ), + ( + "preparationScenarioRemoveRequested", + self.controller.request_preparation_remove_scenario, + ), + ( + "preparationScenarioMoveRequested", + self.controller.request_preparation_move_scenario, + ), + ( + "preparationScenarioFieldChangeRequested", + self.controller.request_preparation_scenario_field_change, + ), + ( + "preparationScenarioKindChangeRequested", + self.controller.request_preparation_scenario_kind_change, + ), + ( + "preparationScenarioPartitionRequested", + self.controller.request_preparation_scenario_partition, + ), + ( + "preparationScenarioRemovePartitionRequested", + self.controller.request_preparation_scenario_remove_partition, + ), + ( + "preparationScenarioCategoricalHoldoutRequested", + self.controller.request_preparation_scenario_categorical_holdout, + ), + ( + "preparationScenarioRangeHoldoutRequested", + self.controller.request_preparation_scenario_range_holdout, + ), + ( + "preparationScenarioCoordinateHoldoutRequested", + self.controller.request_preparation_scenario_coordinate_holdout, + ), + ( + "preparationScenarioRemoveHoldoutRequested", + self.controller.request_preparation_scenario_remove_holdout, + ), + ( + "preparationScenarioStrataRequested", + self.controller.request_preparation_scenario_strata, + ), + ( + "preparationScenarioNumericBinsRequested", + self.controller.request_preparation_scenario_numeric_bins, + ), + ( + "preparationScenarioRemoveNumericBinsRequested", + self.controller.request_preparation_scenario_remove_numeric_bins, + ), + ( + "preparationScenarioTransformationAddRequested", + self.controller.request_preparation_scenario_transformation_add, + ), + ( + "preparationScenarioTransformationRemoveRequested", + self.controller.request_preparation_scenario_transformation_remove, + ), + ( + "preparationScenarioTransformationMoveRequested", + self.controller.request_preparation_scenario_transformation_move, + ), + ( + "preparationTextFieldRequested", + self.controller.request_preparation_text_field, + ), + ( + "configurationImportRequested", + self.controller.request_import_configuration, + ), ("datasetSaveRequested", self.controller.request_save), ("datasetSaveAsRequested", self.controller.request_save_as), ( @@ -552,6 +669,14 @@ def _connect_qml_facade(self, root: QObject) -> None: ("inspectionInspectRequested", self.controller.request_inspect_source), ("inspectionExploreRequested", self.controller.request_inspection_explore), ("inspectionRefreshRequested", self.controller.request_refresh_inspection), + ( + "preparationSourceBindRequested", + self.controller.request_bind_inspected_preparation_source, + ), + ( + "preparationSourceClearRequested", + self.controller.request_clear_preparation_source, + ), ( "inspectionSourcesRefreshRequested", self.controller.request_refresh_inspection_sources, @@ -953,7 +1078,7 @@ def _exercise_installed_qml_smoke(runtime: QmlApplicationRuntime) -> None: if len(roots) != 1: raise QmlStartupError("installed QML smoke lost its root object") root = roots[0] - if root.property("configController") is not runtime.controller.dataset_config_controller: + if root.property("configController") is not runtime.controller.configuration_controller: raise QmlStartupError("installed QML smoke did not bind the configuration controller") if not root.setProperty("width", 1024) or not root.setProperty("height", 768): raise QmlStartupError("installed QML smoke could not resize the workbench") @@ -965,7 +1090,7 @@ def _exercise_installed_qml_smoke(runtime: QmlApplicationRuntime) -> None: raise QmlStartupError("installed QML smoke did not apply responsive controller state") if root.findChild(QObject, "yamlPreviewPage") is None: raise QmlStartupError("installed QML smoke did not instantiate the YAML page") - if runtime.controller.dataset_config_controller.get_yaml_available(): + if runtime.controller.configuration_controller.get_yaml_available(): raise QmlStartupError("installed QML smoke unexpectedly exposed YAML without a document") if runtime.warning_capture.runtime_warnings: details = "\n".join(runtime.warning_capture.runtime_warnings) diff --git a/src/carnopy/app/request_coordinator.py b/src/carnopy/app/request_coordinator.py index 92896d4..11de7a4 100644 --- a/src/carnopy/app/request_coordinator.py +++ b/src/carnopy/app/request_coordinator.py @@ -23,7 +23,13 @@ _OWNER_REQUESTS: dict[RequestOwner, frozenset[RequestType]] = { "configuration": frozenset( - {"describe_capabilities", "load_dataset_config", "validate_dataset_config"} + { + "describe_capabilities", + "load_configuration", + "validate_configuration", + "load_dataset_config", + "validate_dataset_config", + } ), "execution": frozenset({"validate_config", "generate_dataset"}), "inspection": frozenset({"inspect_source", "preview_table"}), @@ -185,6 +191,10 @@ def force_stop_available(self) -> bool: and not self._termination_protected ) + @property + def termination_protected(self) -> bool: + return self._termination_protected + @property def outcome(self) -> RequestOutcome | None: return self._outcome diff --git a/src/carnopy/app/scenario_draft.py b/src/carnopy/app/scenario_draft.py new file mode 100644 index 0000000..4f566ba --- /dev/null +++ b/src/carnopy/app/scenario_draft.py @@ -0,0 +1,641 @@ +from __future__ import annotations + +import copy +import math +from collections.abc import Mapping, Sequence +from itertools import pairwise +from typing import Any + +from pydantic import ValidationError +from PySide6.QtCore import Property, QObject, Signal, Slot + +from carnopy.app.field_ids import preparation_scenario_field +from carnopy.app.workflow_models import WorkflowListModel + +SCENARIO_KINDS = ( + "unsplit", + "shuffle", + "stratified_hash", + "coordinate_block", + "range_holdout", + "leave_fluid_out", + "phase_holdout", + "model_holdout", +) +PARTITIONS = ("train", "validation", "test", "all") +TRANSFORM_METHODS = ("log10", "standard", "minmax", "robust") + + +class ScenarioDraft(QObject): + """Own one detached, Python-authoritative Preparation scenario edit.""" + + changed = Signal() + validity_changed = Signal() + field_choices_changed = Signal() + kind_change_requested = Signal(str) + + def __init__( + self, + *, + field_choices: Sequence[str] = (), + payload: Mapping[str, object] | None = None, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + self.partition_rows = WorkflowListModel(("partition", "ratio"), self) + self.holdout_rows = WorkflowListModel(("partition", "summary", "kind"), self) + self.strata_rows = WorkflowListModel(("field",), self) + self.numeric_bin_rows = WorkflowListModel( + ("field", "boundaries", "summary"), + self, + ) + self.transformation_rows = WorkflowListModel( + ("field", "methods", "summary"), + self, + ) + self._field_choices = _unique_strings(field_choices) + self._name = "scenario" + self._kind = "unsplit" + self._seed_text = "" + self._field = "" + self._remainder = "" + self._partitions: dict[str, float] = {"all": 1.0} + self._holdouts: dict[str, Any] = {} + self._strata_categorical: tuple[str, ...] = () + self._numeric_bins: dict[str, tuple[float, ...]] = {} + self._transformations: tuple[dict[str, Any], ...] = () + if payload is None: + self._refresh_models() + else: + self.load_payload(payload) + + def get_name(self) -> str: + return self._name + + @Slot(str, result=bool) + def set_name(self, value: str) -> bool: + return self._set_scalar("_name", value) + + def _set_name_property(self, value: str) -> None: + self.set_name(value) + + name = Property(str, get_name, _set_name_property, notify=changed) + + def get_kind(self) -> str: + return self._kind + + kind = Property(str, get_kind, notify=changed) + + def get_seed_text(self) -> str: + return self._seed_text + + @Slot(str, result=bool) + def set_seed_text(self, value: str) -> bool: + return self._set_scalar("_seed_text", value.strip()) + + def _set_seed_text_property(self, value: str) -> None: + self.set_seed_text(value) + + seedText = Property(str, get_seed_text, _set_seed_text_property, notify=changed) + + def get_field(self) -> str: + return self._field + + @Slot(str, result=bool) + def set_field(self, value: str) -> bool: + return self._set_scalar("_field", value.strip()) + + def _set_field_property(self, value: str) -> None: + self.set_field(value) + + field = Property(str, get_field, _set_field_property, notify=changed) + + def get_remainder(self) -> str: + return self._remainder + + @Slot(str, result=bool) + def set_remainder(self, value: str) -> bool: + return self._set_scalar("_remainder", value.strip()) + + def _set_remainder_property(self, value: str) -> None: + self.set_remainder(value) + + remainder = Property(str, get_remainder, _set_remainder_property, notify=changed) + + def get_kind_choices(self) -> list[str]: + return list(SCENARIO_KINDS) + + kindChoices = Property(list, get_kind_choices, constant=True) + + def get_partition_choices(self) -> list[str]: + return list(PARTITIONS) + + partitionChoices = Property(list, get_partition_choices, constant=True) + + def get_field_choices(self) -> list[str]: + referenced = [self._field, *self._numeric_bins] + for holdout in self._holdouts.values(): + if isinstance(holdout, Mapping) and set(holdout) != {"min", "max"}: + referenced.extend(str(field) for field in holdout) + referenced.extend(str(item.get("field", "")) for item in self._transformations) + return list(_unique_strings((*self._field_choices, *referenced))) + + fieldChoices = Property(list, get_field_choices, notify=field_choices_changed) + + def get_transform_method_choices(self) -> list[str]: + return list(TRANSFORM_METHODS) + + transformationMethodChoices = Property( + list, + get_transform_method_choices, + constant=True, + ) + + def get_strata_categorical_text(self) -> str: + return ", ".join(self._strata_categorical) + + strataCategoricalText = Property(str, get_strata_categorical_text, notify=changed) + + def get_partition_rows(self) -> QObject: + return self.partition_rows + + partitionsModel = Property(QObject, get_partition_rows, constant=True) + + def get_holdout_rows(self) -> QObject: + return self.holdout_rows + + holdoutsModel = Property(QObject, get_holdout_rows, constant=True) + + def get_strata_rows(self) -> QObject: + return self.strata_rows + + strataCategoricalModel = Property(QObject, get_strata_rows, constant=True) + + def get_numeric_bin_rows(self) -> QObject: + return self.numeric_bin_rows + + numericBinsModel = Property(QObject, get_numeric_bin_rows, constant=True) + + def get_transformation_rows(self) -> QObject: + return self.transformation_rows + + transformationsModel = Property(QObject, get_transformation_rows, constant=True) + + def get_locally_valid(self) -> bool: + return not self.get_issue() + + locallyValid = Property(bool, get_locally_valid, notify=validity_changed) + + def get_issue(self) -> str: + try: + self.payload() + except ValueError as exc: + return str(exc) + return "" + + issue = Property(str, get_issue, notify=validity_changed) + + def get_first_invalid_field(self) -> str: + issue = self.get_issue().casefold() + if not issue: + return "" + if "name" in issue: + field = "name" + elif "partition" in issue: + field = "partitions" + elif "holdout" in issue or "remainder" in issue: + field = "holdouts" + elif "strat" in issue or "bin" in issue: + field = "strata" + elif "transform" in issue: + field = "transformations" + elif "seed" in issue: + field = "seed" + elif "field" in issue: + field = "field" + else: + field = "kind" + return preparation_scenario_field(field) + + firstInvalidField = Property(str, get_first_invalid_field, notify=validity_changed) + + def get_first_invalid_row(self) -> int: + field = self.get_first_invalid_field() + if field.endswith(".partitions") and self.partition_rows.get_count(): + return 0 + if field.endswith(".holdouts") and self.holdout_rows.get_count(): + return 0 + if field.endswith(".strata"): + if self.strata_rows.get_count(): + return 0 + if self.numeric_bin_rows.get_count(): + return 0 + if field.endswith(".transformations") and self.transformation_rows.get_count(): + return 0 + return -1 + + firstInvalidRow = Property(int, get_first_invalid_row, notify=validity_changed) + + def load_payload(self, payload: Mapping[str, object]) -> None: + from carnopy.preparation.models import ScenarioConfig + + validated = ScenarioConfig.model_validate(payload) + value = validated.model_dump(mode="json", exclude_none=True) + self._name = str(value["name"]) + self._kind = str(value["kind"]) + seed = value.get("seed") + self._seed_text = "" if seed is None else str(seed) + self._field = str(value.get("field", "")) + self._remainder = str(value.get("remainder", "")) + self._partitions = { + str(key): float(item) for key, item in _mapping(value.get("partitions")).items() + } + self._holdouts = copy.deepcopy(_mapping(value.get("holdouts"))) + strata = _mapping(value.get("strata")) + self._strata_categorical = _strings(strata.get("categorical")) + self._numeric_bins = { + str(field): tuple(float(item) for item in boundaries) + for field, boundaries in _mapping(strata.get("numeric_bins")).items() + if isinstance(boundaries, list | tuple) + } + transformations = value.get("transformations") + self._transformations = ( + tuple(copy.deepcopy(item) for item in transformations if isinstance(item, dict)) + if isinstance(transformations, list) + else () + ) + self._refresh_models() + self.validity_changed.emit() + self.field_choices_changed.emit() + self.changed.emit() + + def set_field_choices(self, values: Sequence[str]) -> bool: + updated = _unique_strings(values) + if updated == self._field_choices: + return False + self._field_choices = updated + self.field_choices_changed.emit() + return True + + @Slot(str, result=bool) + def request_kind_change(self, value: str) -> bool: + if value not in SCENARIO_KINDS or value == self._kind: + return False + if self._shape_has_state(): + self.kind_change_requested.emit(value) + return False + return self.apply_kind_change(value, True) + + @Slot(str, bool, result=bool) + def apply_kind_change(self, value: str, confirmed: bool) -> bool: + if not confirmed or value not in SCENARIO_KINDS or value == self._kind: + return False + self._kind = value + self._field = "" + self._remainder = "" + self._holdouts = {} + self._strata_categorical = () + self._numeric_bins = {} + if value == "unsplit": + self._partitions = {"all": 1.0} + elif value in {"shuffle", "stratified_hash"}: + self._partitions = {"train": 0.8, "test": 0.2} + if not self._seed_text: + self._seed_text = "42" + else: + self._partitions = {} + self._remainder = "train" + self._state_changed() + return True + + @Slot(str, str, result=bool) + def set_partition(self, partition: str, raw_ratio: str) -> bool: + if partition not in PARTITIONS: + return False + try: + ratio = _finite_float(raw_ratio, "partition ratio") + except ValueError: + return False + if self._partitions.get(partition) == ratio: + return False + self._partitions[partition] = ratio + self._state_changed() + return True + + @Slot(str, result=bool) + def remove_partition(self, partition: str) -> bool: + if partition not in self._partitions: + return False + del self._partitions[partition] + self._state_changed() + return True + + @Slot(str, str, result=bool) + def set_categorical_holdout(self, partition: str, comma_values: str) -> bool: + if partition not in PARTITIONS or partition == "all": + return False + values = tuple(item.strip() for item in comma_values.split(",") if item.strip()) + if not values or len(values) != len(set(values)): + return False + return self._set_holdout(partition, list(values)) + + @Slot(str, str, str, result=bool) + def set_range_holdout(self, partition: str, raw_minimum: str, raw_maximum: str) -> bool: + if partition not in PARTITIONS or partition == "all": + return False + try: + minimum = _finite_float(raw_minimum, "range minimum") + maximum = _finite_float(raw_maximum, "range maximum") + except ValueError: + return False + if maximum < minimum: + return False + return self._set_holdout(partition, {"min": minimum, "max": maximum}) + + @Slot(str, str, str, str, result=bool) + def set_coordinate_holdout( + self, + partition: str, + field: str, + raw_minimum: str, + raw_maximum: str, + ) -> bool: + cleaned = field.strip() + if partition not in PARTITIONS or partition == "all" or not cleaned: + return False + try: + minimum = _finite_float(raw_minimum, "coordinate minimum") + maximum = _finite_float(raw_maximum, "coordinate maximum") + except ValueError: + return False + if maximum < minimum: + return False + current = self._holdouts.get(partition) + block = copy.deepcopy(current) if isinstance(current, dict) else {} + block[cleaned] = {"min": minimum, "max": maximum} + return self._set_holdout(partition, block) + + @Slot(str, str, result=bool) + def remove_coordinate_field(self, partition: str, field: str) -> bool: + current = self._holdouts.get(partition) + if not isinstance(current, dict) or field not in current: + return False + block = copy.deepcopy(current) + del block[field] + if block: + self._holdouts[partition] = block + else: + del self._holdouts[partition] + self._state_changed() + return True + + @Slot(str, result=bool) + def remove_holdout(self, partition: str) -> bool: + if partition not in self._holdouts: + return False + del self._holdouts[partition] + self._state_changed() + return True + + @Slot(str, result=bool) + def set_strata_categorical(self, comma_fields: str) -> bool: + values = tuple(item.strip() for item in comma_fields.split(",") if item.strip()) + if len(values) != len(set(values)) or values == self._strata_categorical: + return False + self._strata_categorical = values + self._state_changed() + return True + + @Slot(str, str, result=bool) + def set_numeric_bins(self, field: str, comma_boundaries: str) -> bool: + cleaned = field.strip() + if not cleaned: + return False + try: + boundaries = tuple( + _finite_float(item.strip(), "numeric bin boundary") + for item in comma_boundaries.split(",") + if item.strip() + ) + except ValueError: + return False + if not boundaries or any(right <= left for left, right in pairwise(boundaries)): + return False + if self._numeric_bins.get(cleaned) == boundaries: + return False + self._numeric_bins[cleaned] = boundaries + self._state_changed() + return True + + @Slot(str, result=bool) + def remove_numeric_bins(self, field: str) -> bool: + if field not in self._numeric_bins: + return False + del self._numeric_bins[field] + self._state_changed() + return True + + @Slot(str, str, result=bool) + def add_transformation(self, field: str, comma_methods: str) -> bool: + cleaned = field.strip() + methods = tuple(item.strip() for item in comma_methods.split(",") if item.strip()) + if ( + not cleaned + or not methods + or len(methods) != len(set(methods)) + or any(item not in TRANSFORM_METHODS for item in methods) + ): + return False + updated = [*self._transformations, {"field": cleaned, "methods": list(methods)}] + if not _transformations_valid(updated): + return False + self._transformations = tuple(updated) + self._state_changed() + return True + + @Slot(int, result=bool) + def remove_transformation(self, row: int) -> bool: + if not 0 <= row < len(self._transformations): + return False + self._transformations = ( + *self._transformations[:row], + *self._transformations[row + 1 :], + ) + self._state_changed() + return True + + @Slot(int, int, result=bool) + def move_transformation(self, source: int, destination: int) -> bool: + values = list(self._transformations) + if ( + not 0 <= source < len(values) + or not 0 <= destination < len(values) + or source == destination + ): + return False + item = values.pop(source) + values.insert(destination, item) + self._transformations = tuple(values) + self._state_changed() + return True + + def payload(self) -> dict[str, Any]: + from carnopy.preparation.models import ScenarioConfig + + result: dict[str, Any] = {"name": self._name.strip(), "kind": self._kind} + if self._seed_text: + try: + result["seed"] = int(self._seed_text) + except ValueError as exc: + raise ValueError("scenario seed must be an integer") from exc + if self._partitions: + result["partitions"] = copy.deepcopy(self._partitions) + if self._field: + result["field"] = self._field + if self._holdouts: + result["holdouts"] = copy.deepcopy(self._holdouts) + if self._remainder: + result["remainder"] = self._remainder + if self._strata_categorical or self._numeric_bins: + result["strata"] = { + "categorical": list(self._strata_categorical), + "numeric_bins": { + field: list(boundaries) for field, boundaries in self._numeric_bins.items() + }, + } + if self._transformations: + result["transformations"] = copy.deepcopy(list(self._transformations)) + try: + validated = ScenarioConfig.model_validate(result) + except ValidationError as exc: + raise ValueError(str(exc)) from exc + return validated.model_dump(mode="json", exclude_none=True) + + def detached_payload(self) -> dict[str, Any]: + return copy.deepcopy(self.payload()) + + def raw_state(self) -> tuple[object, ...]: + return ( + self._name, + self._kind, + self._seed_text, + self._field, + self._remainder, + tuple(self._partitions.items()), + copy.deepcopy(self._holdouts), + self._strata_categorical, + tuple(self._numeric_bins.items()), + copy.deepcopy(self._transformations), + ) + + def _shape_has_state(self) -> bool: + default_partitions = ( + self._partitions in ({}, {"all": 1.0}) + if self._kind == "unsplit" + else not self._partitions + ) + return bool( + not default_partitions + or self._holdouts + or self._field + or self._remainder + or self._strata_categorical + or self._numeric_bins + ) + + def _set_scalar(self, attribute: str, value: str) -> bool: + if getattr(self, attribute) == value: + return False + setattr(self, attribute, value) + self._state_changed() + return True + + def _set_holdout(self, partition: str, value: object) -> bool: + if self._holdouts.get(partition) == value: + return False + self._holdouts[partition] = copy.deepcopy(value) + self._state_changed() + return True + + def _state_changed(self) -> None: + self._refresh_models() + self.validity_changed.emit() + self.field_choices_changed.emit() + self.changed.emit() + + def _refresh_models(self) -> None: + self.partition_rows.replace( + {"partition": partition, "ratio": ratio} + for partition, ratio in self._partitions.items() + ) + self.holdout_rows.replace( + { + "partition": partition, + "summary": _holdout_summary(value), + "kind": self._kind, + } + for partition, value in self._holdouts.items() + ) + self.strata_rows.replace({"field": field} for field in self._strata_categorical) + self.numeric_bin_rows.replace( + { + "field": field, + "boundaries": list(boundaries), + "summary": ", ".join(_number_text(item) for item in boundaries), + } + for field, boundaries in self._numeric_bins.items() + ) + self.transformation_rows.replace( + { + "field": str(item.get("field", "")), + "methods": list(item.get("methods", [])), + "summary": f"{item.get('field', '')} · {' → '.join(item.get('methods', []))}", + } + for item in self._transformations + ) + + +def _mapping(value: object) -> dict[str, Any]: + return copy.deepcopy(value) if isinstance(value, dict) else {} + + +def _strings(value: object) -> tuple[str, ...]: + if not isinstance(value, list | tuple): + return () + return tuple(str(item) for item in value) + + +def _unique_strings(values: Sequence[str]) -> tuple[str, ...]: + return tuple(dict.fromkeys(str(item) for item in values if str(item))) + + +def _finite_float(value: str, label: str) -> float: + try: + number = float(value) + except ValueError as exc: + raise ValueError(f"{label} must be numeric") from exc + if not math.isfinite(number): + raise ValueError(f"{label} must be finite") + return 0.0 if number == 0.0 else number + + +def _transformations_valid(values: list[dict[str, Any]]) -> bool: + outputs = [ + f"{item['field']}__{'__'.join(str(method) for method in item['methods'])}" + for item in values + ] + return len(outputs) == len(set(outputs)) + + +def _holdout_summary(value: object) -> str: + if isinstance(value, list): + return ", ".join(str(item) for item in value) + if isinstance(value, dict): + if set(value) == {"min", "max"}: + return f"{_number_text(value['min'])} … {_number_text(value['max'])}" + return "; ".join(f"{field}: {_holdout_summary(bounds)}" for field, bounds in value.items()) + return str(value) + + +def _number_text(value: object) -> str: + return format(float(value), ".12g") if isinstance(value, int | float) else str(value) diff --git a/src/carnopy/app/source_inspection.py b/src/carnopy/app/source_inspection.py index 7dccf52..840d6db 100644 --- a/src/carnopy/app/source_inspection.py +++ b/src/carnopy/app/source_inspection.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import hashlib import json import re @@ -10,8 +11,18 @@ from carnopy.app.plot_context import build_plot_context from carnopy.domain.failures import ConfigError +from carnopy.domain.properties import PROPERTY_REGISTRY from carnopy.inspection import PreparationInspection, SweepInspection, inspect_source -from carnopy.preparation.source import load_preparation_source +from carnopy.preparation.derived import DERIVED_FEATURE_REGISTRY +from carnopy.preparation.fields import ( + CATEGORICAL_FIELDS, + ResolvedField, + compute_derived_value, + preparation_field_capabilities, +) +from carnopy.preparation.models import DerivedFeature +from carnopy.preparation.reference import assess_reference_context +from carnopy.preparation.source import LoadedPreparationSource, load_preparation_source from carnopy.provenance import sha256_file from carnopy.visualization.inspect import PlotInspection from carnopy.visualization.models import VisualizationError @@ -65,6 +76,8 @@ class ResolvedInspection: preparation_eligible: bool = False preparation_ineligible_reason: str = "" preparation_source_descriptor: dict[str, Any] | None = None + preparation_profile: dict[str, Any] | None = None + preparation_audit: dict[str, Any] | None = None def public_payload(self) -> dict[str, Any]: return { @@ -78,6 +91,8 @@ def public_payload(self) -> dict[str, Any]: "preparation_eligible": self.preparation_eligible, "preparation_ineligible_reason": self.preparation_ineligible_reason, "preparation_source_descriptor": self.preparation_source_descriptor, + "preparation_profile": self.preparation_profile, + "preparation_audit": self.preparation_audit, } @@ -90,6 +105,14 @@ class ResolvedCatalog: controls: dict[str, Any] +@dataclass(frozen=True) +class ResolvedScenarioAudit: + name: str + summary: dict[str, Any] + path: Path + sha256: str + + def inspect_for_app(source: str | Path) -> ResolvedInspection: requested = Path(source).expanduser().absolute() catalog = _resolve_catalog(requested) @@ -108,10 +131,18 @@ def inspect_for_app(source: str | Path) -> ResolvedInspection: if kind != catalog.source_kind: raise VisualizationError("inspection source classification changed during inspection") summary = inspection.to_dict() - eligible, ineligible_reason, preparation_descriptor = _preparation_eligibility( - requested, - kind, - catalog, + preparation_audit = ( + _preparation_audit_payload(requested, inspection.manifest) + if isinstance(inspection, PreparationInspection) + else None + ) + eligible, ineligible_reason, preparation_descriptor, preparation_profile = ( + _preparation_eligibility( + requested, + kind, + catalog, + summary, + ) ) return ResolvedInspection( source=requested, @@ -124,6 +155,8 @@ def inspect_for_app(source: str | Path) -> ResolvedInspection: preparation_eligible=eligible, preparation_ineligible_reason=ineligible_reason, preparation_source_descriptor=preparation_descriptor, + preparation_profile=preparation_profile, + preparation_audit=preparation_audit, ) @@ -177,6 +210,14 @@ def _resolve_catalog(source: Path) -> ResolvedCatalog: "scenario_report.json", ), ) + scenario_audits = _resolve_scenario_audits(source, manifest) + if scenario_audits is not None: + controls.update( + { + f"scenario:{item.name}": _control_descriptor(item.path) + for item in scenario_audits + } + ) return ResolvedCatalog( "preparation", _catalog_revision("preparation", tables, controls), @@ -300,6 +341,120 @@ def _sweep_tables(root: Path, metadata: dict[str, Any]) -> tuple[ResolvedTable, return tuple(tables) +def _preparation_audit_payload( + root: Path, + manifest: dict[str, Any], +) -> dict[str, Any] | None: + artifacts = _resolve_scenario_audits(root, manifest) + if artifacts is None: + return None + return { + "audit_schema_version": 1, + "scenario_details": [_read_scenario_audit(artifact) for artifact in artifacts], + } + + +def _resolve_scenario_audits( + root: Path, + manifest: dict[str, Any], +) -> tuple[ResolvedScenarioAudit, ...] | None: + scenario_summary = manifest.get("scenarios") + if not isinstance(scenario_summary, dict): + return None + raw_scenarios = scenario_summary.get("scenarios") + if not isinstance(raw_scenarios, list): + raise VisualizationError("preparation scenario summary does not contain a scenario list") + if not raw_scenarios: + return () + if any( + not isinstance(item, dict) + or "scenario_artifact" not in item + or "artifact_hashes" not in item + for item in raw_scenarios + ): + # Bundles finalized before scenario audit artifacts were recorded remain + # inspectable, but cannot claim verified leakage evidence. + return None + resolved: list[ResolvedScenarioAudit] = [] + names: set[str] = set() + paths: set[str] = set() + for raw_item in raw_scenarios: + item = cast(dict[str, Any], raw_item) + name = item.get("name") + relative = item.get("scenario_artifact") + raw_hashes = item.get("artifact_hashes") + if not isinstance(name, str) or not name: + raise VisualizationError("preparation scenario audit has an invalid scenario name") + if name in names: + raise VisualizationError(f"preparation scenario audit repeats scenario {name!r}") + if not isinstance(relative, str) or not relative: + raise VisualizationError(f"preparation scenario {name!r} has an invalid artifact path") + if relative != f"data/scenarios/{name}/scenario.json": + raise VisualizationError( + f"preparation scenario {name!r} has an unexpected audit artifact path" + ) + if relative in paths: + raise VisualizationError("preparation scenarios share one scenario audit artifact") + if not isinstance(raw_hashes, dict): + raise VisualizationError( + f"preparation scenario {name!r} does not contain artifact hashes" + ) + expected = raw_hashes.get(relative) + if not isinstance(expected, str) or not expected: + raise VisualizationError( + f"preparation scenario {name!r} does not record its audit artifact hash" + ) + path = _safe_artifact( + root, + relative, + f"scenario {name} audit", + {relative: expected}, + ) + names.add(name) + paths.add(relative) + resolved.append( + ResolvedScenarioAudit( + name=name, + summary=item, + path=path, + sha256=expected, + ) + ) + return tuple(resolved) + + +def _read_scenario_audit(artifact: ResolvedScenarioAudit) -> dict[str, Any]: + try: + raw = artifact.path.read_bytes() + except OSError as exc: + raise VisualizationError( + f"could not read scenario {artifact.name!r} audit artifact: {exc}" + ) from exc + if hashlib.sha256(raw).hexdigest() != artifact.sha256: + raise VisualizationError(f"scenario {artifact.name} audit artifact hash mismatch") + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise VisualizationError( + f"could not read scenario {artifact.name!r} audit artifact: {exc}" + ) from exc + if not isinstance(value, dict): + raise VisualizationError(f"scenario {artifact.name!r} audit artifact must be an object") + if value.get("name") != artifact.name: + raise VisualizationError(f"scenario {artifact.name!r} audit identity does not match") + if value.get("kind") != artifact.summary.get("kind"): + raise VisualizationError(f"scenario {artifact.name!r} audit kind does not match") + if value.get("partition_counts") != artifact.summary.get("partition_counts"): + raise VisualizationError(f"scenario {artifact.name!r} audit partitions do not match") + leakage = value.get("state_leakage") + if not isinstance(leakage, dict): + raise VisualizationError(f"scenario {artifact.name!r} audit has no leakage evidence") + return { + "name": artifact.name, + "state_leakage": copy.deepcopy(leakage), + } + + def _preparation_tables( root: Path, manifest: dict[str, Any], @@ -330,6 +485,18 @@ def _preparation_tables( ) ) + quality_artifacts = manifest.get("quality_artifacts") + quality_flags = quality_artifacts.get("flags") if isinstance(quality_artifacts, dict) else None + if isinstance(quality_flags, str): + try: + path = _safe_artifact(root, quality_flags, "quality flags", artifact_hashes) + except VisualizationError: + # Quality artifacts are advisory. Shared inspection reports their + # integrity error without making the main prepared bundle unusable. + pass + else: + tables.append(_table("quality_flags", "Quality flags", path)) + scenarios = manifest.get("scenarios") scenario_items = scenarios.get("scenarios") if isinstance(scenarios, dict) else None if isinstance(scenario_items, list): @@ -544,25 +711,212 @@ def _preparation_eligibility( source: Path, source_kind: str, catalog: ResolvedCatalog, -) -> tuple[bool, str, dict[str, Any] | None]: + summary: dict[str, Any], +) -> tuple[bool, str, dict[str, Any] | None, dict[str, Any] | None]: if source_kind == "preparation": - return False, "prepared bundles cannot be used as preparation sources", None + return False, "prepared bundles cannot be used as preparation sources", None, None if source_kind == "dataset" and not source.is_dir(): return ( False, "standalone CSV and Parquet files cannot be used as preparation sources", None, + None, ) descriptor = _preparation_descriptor(source, source_kind, catalog) try: - load_preparation_source( + loaded = load_preparation_source( source, allow_partial_sweep=True, accepted_descriptor=descriptor, ) except ConfigError as exc: - return False, str(exc), None - return True, "", descriptor + return False, str(exc), None, None + return ( + True, + "", + descriptor, + _preparation_profile( + loaded, + inspection_revision=catalog.revision, + inspection_summary=summary, + ), + ) + + +def _preparation_profile( + source_data: LoadedPreparationSource, + *, + inspection_revision: str, + inspection_summary: dict[str, Any], +) -> dict[str, Any]: + fields = preparation_field_capabilities(source_data.tables) + numeric = [_field_profile(field) for field in fields.numeric] + categorical = [_field_profile(field) for field in fields.categorical] + auxiliary = [_field_profile(field) for field in fields.auxiliary] + available_models = _available_models(source_data) + declared_models = _string_values(source_data.source_identity.get("models")) + reference_model = inspection_summary.get("reference_model") + if not isinstance(reference_model, str): + reference_model = available_models[0] if len(available_models) == 1 else None + completion_status = _completion_status(source_data) + model_holdout_available = ( + source_data.source_kind == "model_sweep" and len(available_models) >= 2 + ) + return { + "profile_schema_version": 1, + "source_path": str(source_data.requested_path), + "source_kind": source_data.source_kind, + "inspection_revision": inspection_revision, + "source_identity": copy.deepcopy(source_data.source_identity), + "completion": { + "status": completion_status, + "partial": source_data.partial_sweep_source, + "included_child_models": list(source_data.included_child_models), + "missing_child_models": list(source_data.missing_child_models), + }, + "available_models": available_models, + "declared_models": declared_models, + "reference_model": reference_model, + "numeric_candidates": numeric, + "target_candidates": copy.deepcopy(numeric), + "categorical_candidates": categorical, + "auxiliary_candidates": auxiliary, + "observed_category_values": _observed_category_values(source_data), + "derived_features": _derived_feature_profiles(source_data), + "model_holdout": { + "available": model_holdout_available, + "reason": ( + "" + if model_holdout_available + else _model_holdout_unavailable_reason(source_data, available_models) + ), + }, + "reference_context": assess_reference_context(source_data), + } + + +def _field_profile(field: ResolvedField) -> dict[str, Any]: + definition = PROPERTY_REGISTRY.get(field.semantic_name) + return { + "name": field.semantic_name, + "column": field.column, + "unit": field.unit, + "source": field.source, + "reference_dependent": bool(definition is not None and definition.reference_dependent), + } + + +def _available_models(source_data: LoadedPreparationSource) -> list[str]: + return list( + dict.fromkeys( + model + for table in source_data.tables + if (model := table.backend_model) is not None and model + ) + ) + + +def _completion_status(source_data: LoadedPreparationSource) -> str: + if source_data.source_kind == "model_sweep": + value = source_data.source_identity.get("sweep_status") + else: + value = source_data.tables[0].metadata.get("run_status") + return value if isinstance(value, str) and value else "completed" + + +def _observed_category_values( + source_data: LoadedPreparationSource, +) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for field, column in CATEGORICAL_FIELDS.items(): + values = { + str(value) + for table in source_data.tables + if column in table.frame.columns + for value in table.frame[column].dropna().tolist() + } + if values: + result[field] = sorted(values) + return result + + +def _derived_feature_profiles( + source_data: LoadedPreparationSource, +) -> list[dict[str, Any]]: + total_rows = sum(len(table.frame) for table in source_data.tables) + profiles: list[dict[str, Any]] = [] + for name, definition in DERIVED_FEATURE_REGISTRY.items(): + ready_rows = 0 + reason_codes: set[str] = set() + missing_dependencies: set[str] = set() + for table in source_data.tables: + for _, row in table.frame.iterrows(): + try: + _, reasons, missing = compute_derived_value( + cast(DerivedFeature, name), + row, + table, + ) + except (TypeError, ValueError, OverflowError): + reasons = ["invalid_derived_dependency"] + missing = list(definition.dependencies) + if reasons: + reason_codes.update(reasons) + missing_dependencies.update(missing) + else: + ready_rows += 1 + if ready_rows == total_rows and total_rows: + status = "ready" + reason = "" + elif ready_rows: + status = "partial" + reason = ( + f"Available for {ready_rows} of {total_rows} source rows; " + "other rows would be excluded." + ) + else: + status = "unavailable" + reason = ( + "The source contains no rows with every required dependency." + if total_rows + else "The source contains no rows." + ) + profiles.append( + { + "name": name, + "status": status, + "available": ready_rows > 0, + "ready_row_count": ready_rows, + "source_row_count": total_rows, + "reason": reason, + "reason_codes": sorted(reason_codes), + "missing_dependencies": [ + dependency + for dependency in definition.dependencies + if dependency in missing_dependencies + ], + "dependencies": list(definition.dependencies), + "unit": definition.unit, + } + ) + return profiles + + +def _model_holdout_unavailable_reason( + source_data: LoadedPreparationSource, + available_models: list[str], +) -> str: + if source_data.source_kind != "model_sweep": + return "Model holdout scenarios require a model-sweep source." + if len(available_models) < 2: + return "Model holdout scenarios require at least two available sweep child models." + return "" + + +def _string_values(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str)] def _preparation_descriptor( diff --git a/src/carnopy/app/sweep_draft.py b/src/carnopy/app/sweep_draft.py new file mode 100644 index 0000000..640a5a5 --- /dev/null +++ b/src/carnopy/app/sweep_draft.py @@ -0,0 +1,784 @@ +from __future__ import annotations + +import copy +from collections.abc import Mapping +from typing import Any + +from pydantic import ValidationError +from PySide6.QtCore import Property, QObject, Signal, Slot + +from carnopy.app.comparison_plot_draft import ( + COMPARISON_FILTER_FIELDS, + PLOT_FORMATS, + ComparisonPlotDraft, +) +from carnopy.app.dataset_draft import DatasetDraft +from carnopy.app.draft_models import DraftItem, DraftListModel + +MODEL_DISPLAY_NAMES = { + "heos": "Helmholtz Equation of State (HEOS)", + "pr": "Peng-Robinson (PR)", + "srk": "Soave-Redlich-Kwong (SRK)", +} + + +class SweepDraft(QObject): + """Compose editable sweep fields from the proven dataset draft models.""" + + changed = Signal() + validity_changed = Signal() + dirty_changed = Signal() + active_comparison_draft_changed = Signal() + message = Signal(str) + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self.dataset_draft = DatasetDraft(self) + self.model_choices = DraftListModel(self) + self.comparison_plots_model = DraftListModel(self) + self._capabilities: dict[str, Any] | None = None + self._models: tuple[str, ...] = () + self._selected_models: tuple[str, ...] = () + self._reference_model = "" + self._comparison_format = "png" + self._comparisons: tuple[dict[str, Any], ...] = () + self._active_comparison: ComparisonPlotDraft | None = None + self._active_comparison_row = -1 + self._baseline: dict[str, Any] | None = None + self._baseline_raw: tuple[object, ...] | None = None + self._loaded = False + self._loading = False + self.dataset_draft.changed.connect(self._dataset_changed) + + def get_dataset_draft(self) -> QObject: + return self.dataset_draft + + datasetDraft = Property(QObject, get_dataset_draft, constant=True) + + def get_model_choices(self) -> QObject: + return self.model_choices + + modelChoices = Property(QObject, get_model_choices, constant=True) + + def get_selected_models(self) -> list[str]: + return list(self._selected_models) + + selectedModels = Property(list, get_selected_models, notify=changed) + + def get_comparison_plots_model(self) -> QObject: + return self.comparison_plots_model + + comparisonPlots = Property(QObject, get_comparison_plots_model, constant=True) + + def get_comparison_format(self) -> str: + return self._comparison_format + + @Slot(str, result=bool) + def set_comparison_format(self, value: str) -> bool: + if value not in PLOT_FORMATS or value == self._comparison_format: + return False + self._comparison_format = value + self._state_changed() + return True + + def _set_comparison_format_property(self, value: str) -> None: + self.set_comparison_format(value) + + comparisonFormat = Property( + str, + get_comparison_format, + _set_comparison_format_property, + notify=changed, + ) + + def get_active_comparison_draft(self) -> QObject | None: + return self._active_comparison + + activeComparisonDraft = Property( + QObject, + get_active_comparison_draft, + notify=active_comparison_draft_changed, + ) + + def get_has_active_comparison_edit(self) -> bool: + return self._active_comparison is not None + + hasActiveComparisonEdit = Property( + bool, + get_has_active_comparison_edit, + notify=active_comparison_draft_changed, + ) + + def get_reference_model(self) -> str: + return self._reference_model + + @Slot(str, result=bool) + def set_reference_model(self, value: str) -> bool: + if ( + value not in self._models + or value not in self._selected_models + or value == self._reference_model + ): + return False + self._reference_model = value + self._loading = True + try: + self.dataset_draft.set_model_name(value) + finally: + self._loading = False + self._state_changed() + return True + + def _set_reference_property(self, value: str) -> None: + self.set_reference_model(value) + + referenceModel = Property( + str, + get_reference_model, + _set_reference_property, + notify=changed, + ) + + def get_locally_valid(self) -> bool: + return not self.get_issue() + + locallyValid = Property(bool, get_locally_valid, notify=validity_changed) + + def get_issue(self) -> str: + if not self._loaded: + return "No model sweep configuration is open." + if self._capabilities is None: + return "Sweep capabilities are not loaded." + unavailable = [model for model in self._selected_models if model not in self._models] + if unavailable: + return "Selected models are unavailable in this environment: " + ", ".join(unavailable) + if len(self._selected_models) < 2: + return "Select at least two backend models." + if self._reference_model not in self._selected_models: + return "Select a reference model from the sweep models." + if not self.dataset_draft.get_locally_valid(): + return self.dataset_draft.get_issue() + unsupported = self._unsupported_properties() + if unsupported: + name, models = unsupported[0] + return f"Property {name!r} is unavailable for: {', '.join(models)}." + if self._active_comparison is not None: + return self._active_comparison.get_issue() or ( + "Commit or cancel the active comparison plot edit." + ) + comparison_issue = self._comparison_context_issue() + if comparison_issue is not None: + _row, issue = comparison_issue + return issue + try: + self.payload() + except ValueError as exc: + return str(exc) + return "" + + issue = Property(str, get_issue, notify=validity_changed) + + def get_first_invalid_field(self) -> str: + issue = self.get_issue() + if not issue: + return "" + lowered = issue.casefold() + if self._active_comparison is not None: + return self._active_comparison.get_first_invalid_field() or "sweep.comparison.active" + if "comparison" in lowered: + return "sweep.comparison_plots" + if "reference model" in lowered: + return "sweep.backend.reference_model" + if "model" in lowered: + return "sweep.backend.models" + if not self.dataset_draft.get_locally_valid(): + return self.dataset_draft.get_first_invalid_field().replace("dataset.", "sweep.", 1) + if "property" in lowered: + return "sweep.properties" + return "sweep.configuration" + + firstInvalidField = Property(str, get_first_invalid_field, notify=validity_changed) + + def get_first_invalid_row(self) -> int: + if self._active_comparison is not None: + nested_row = self._active_comparison.get_first_invalid_row() + return nested_row if nested_row >= 0 else self._active_comparison_row + issue = self.get_issue().casefold() + if "models are unavailable" in issue: + return next( + ( + row + for row, model in enumerate(self._selected_models) + if model not in self._models + ), + -1, + ) + if not self.dataset_draft.get_locally_valid(): + return self.dataset_draft.get_first_invalid_row() + unsupported = self._unsupported_properties() + if unsupported: + name = unsupported[0][0] + try: + return self.dataset_draft.selected_property_values().index(name) + except ValueError: # pragma: no cover - guarded by helper input + return -1 + comparison_issue = self._comparison_context_issue() + if comparison_issue is not None: + return comparison_issue[0] + return -1 + + firstInvalidRow = Property(int, get_first_invalid_row, notify=validity_changed) + + def get_dirty(self) -> bool: + if self._baseline is None or self._baseline_raw is None: + return False + try: + return self.payload() != self._baseline + except ValueError: + return self.raw_state() != self._baseline_raw + + dirty = Property(bool, get_dirty, notify=dirty_changed) + + def apply_capabilities(self, payload: Mapping[str, object]) -> None: + self._loading = True + try: + self._capabilities = copy.deepcopy(dict(payload)) + self._models = _string_tuple(payload.get("models")) + self.dataset_draft.apply_capabilities(payload) + if not self._loaded: + if not self._selected_models: + self._selected_models = self._models + if self._reference_model not in self._selected_models: + self._reference_model = ( + self._selected_models[0] if self._selected_models else "" + ) + self._refresh_models() + finally: + self._loading = False + self.validity_changed.emit() + self.changed.emit() + + def load_payload(self, payload: Mapping[str, object]) -> None: + from carnopy.config.sweep import ModelSweepConfig + + validated = ModelSweepConfig.model_validate(payload) + value = validated.model_dump(mode="json", by_alias=True, exclude_none=True) + backend = value["backend"] + assert isinstance(backend, dict) + selected_models = _string_tuple(backend.get("models")) + reference_model = str(backend.get("reference_model", "")) + dataset_payload = copy.deepcopy(value) + dataset_payload["document_type"] = "dataset" + dataset_payload["backend"] = { + "name": "coolprop", + "model": reference_model, + } + dataset_payload.pop("comparison_plots", None) + comparisons = value.get("comparison_plots") + self._loading = True + try: + permissive = _permissive_dataset_capabilities( + dataset_payload, + reference_model=reference_model, + current=self._capabilities, + ) + self.dataset_draft.apply_capabilities(permissive) + self.dataset_draft.load_payload(dataset_payload) + if self._capabilities is not None: + self.dataset_draft.apply_capabilities(self._capabilities) + self._loaded = True + self._selected_models = selected_models + self._reference_model = reference_model + if isinstance(comparisons, dict): + self._comparison_format = str(comparisons.get("format", "png")) + plots = comparisons.get("plots") + self._comparisons = ( + tuple(copy.deepcopy(dict(item)) for item in plots if isinstance(item, Mapping)) + if isinstance(plots, list) + else () + ) + else: + self._comparison_format = "png" + self._comparisons = () + self._discard_active_comparison() + self._refresh_models() + finally: + self._loading = False + self._baseline = copy.deepcopy(value) + self._baseline_raw = self.raw_state() + self.validity_changed.emit() + self.dirty_changed.emit() + self.changed.emit() + + def clear(self) -> None: + self._loading = True + try: + self.dataset_draft.clear() + self._loaded = False + self._selected_models = () + self._reference_model = "" + self._comparison_format = "png" + self._comparisons = () + self._discard_active_comparison() + self._baseline = None + self._baseline_raw = None + self._refresh_models() + finally: + self._loading = False + self.validity_changed.emit() + self.dirty_changed.emit() + self.changed.emit() + + def mark_baseline(self) -> None: + issue = self.get_issue() + if issue: + raise ValueError("cannot mark an invalid model sweep draft as saved") + value = self.payload() + self.dataset_draft.mark_baseline() + self._baseline = value + self._baseline_raw = self.raw_state() + self.dirty_changed.emit() + + def payload(self) -> dict[str, Any]: + if self._active_comparison is not None: + raise ValueError("Commit or cancel the active comparison plot edit.") + return self._validated_payload(self._comparisons) + + def raw_state(self) -> tuple[object, ...]: + return ( + self._loaded, + self._selected_models, + self._reference_model, + self.dataset_draft.raw_state(), + self._comparison_format, + copy.deepcopy(self._comparisons), + ) + + def comparison_plots_payload(self) -> dict[str, Any] | None: + if not self._comparisons: + return None + return { + "format": self._comparison_format, + "plots": copy.deepcopy(list(self._comparisons)), + } + + @Slot(str, bool, result=bool) + def set_model_selected(self, model: str, selected: bool) -> bool: + if selected and model not in self._models: + return False + if not selected and model not in self._selected_models: + return False + if not selected and model == self._reference_model: + self.message.emit("Choose another reference model before removing this model.") + return False + values = list(self._selected_models) + if selected and model not in values: + values.append(model) + elif not selected: + values.remove(model) + else: + return False + self._selected_models = tuple(values) + self._state_changed() + return True + + @Slot(str, bool, result=bool) + def apply_mode_change(self, mode: str, confirmed: bool) -> bool: + if not confirmed: + return False + return self.dataset_draft.apply_mode_change(mode) + + @Slot(str, bool, result=bool) + def apply_coordinate_change(self, axis: str, confirmed: bool) -> bool: + if not confirmed: + return False + return self.dataset_draft.set_coordinate(axis) + + @Slot(result=bool) + def begin_add_comparison(self) -> bool: + if not self._loaded or self._active_comparison is not None: + return False + self._active_comparison_row = -1 + self._active_comparison = self._new_comparison_draft(None) + self._active_comparison.validity_changed.connect(self.validity_changed.emit) + self.active_comparison_draft_changed.emit() + self.validity_changed.emit() + return True + + @Slot(int, result=bool) + def begin_edit_comparison(self, row: int) -> bool: + if self._active_comparison is not None or not 0 <= row < len(self._comparisons): + return False + self._active_comparison_row = row + self._active_comparison = self._new_comparison_draft(self._comparisons[row]) + self._active_comparison.validity_changed.connect(self.validity_changed.emit) + self.active_comparison_draft_changed.emit() + self.validity_changed.emit() + return True + + @Slot(result=bool) + def commit_comparison(self) -> bool: + draft = self._active_comparison + if draft is None: + return False + try: + value = draft.detached_payload() + except ValueError as exc: + self.message.emit(str(exc)) + return False + names = [str(item.get("name", "")) for item in self._comparisons] + if value["name"] in names and ( + self._active_comparison_row < 0 or names[self._active_comparison_row] != value["name"] + ): + self.message.emit("Comparison plot names must be unique.") + return False + if issue := self._comparison_value_issue(value): + self.message.emit(issue) + return False + updated = list(self._comparisons) + if self._active_comparison_row < 0: + updated.append(value) + else: + updated[self._active_comparison_row] = value + try: + self._validated_payload(tuple(updated)) + except ValueError as exc: + self.message.emit(str(exc)) + return False + self._comparisons = tuple(updated) + self._discard_active_comparison() + self.active_comparison_draft_changed.emit() + self._state_changed() + return True + + @Slot(result=bool) + def cancel_comparison(self) -> bool: + if self._active_comparison is None: + return False + self._discard_active_comparison() + self.active_comparison_draft_changed.emit() + self.validity_changed.emit() + return True + + @Slot(int, result=bool) + def remove_comparison(self, row: int) -> bool: + if self._active_comparison is not None or not 0 <= row < len(self._comparisons): + return False + self._comparisons = (*self._comparisons[:row], *self._comparisons[row + 1 :]) + self._state_changed() + return True + + @Slot(int, int, result=bool) + def move_comparison(self, source: int, destination: int) -> bool: + if self._active_comparison is not None: + return False + values = list(self._comparisons) + if not 0 <= source < len(values) or not 0 <= destination < len(values): + return False + if source == destination: + return False + item = values.pop(source) + values.insert(destination, item) + self._comparisons = tuple(values) + self._state_changed() + return True + + def comparison_payloads(self) -> tuple[dict[str, Any], ...]: + return tuple(copy.deepcopy(item) for item in self._comparisons) + + def _new_comparison_draft( + self, + payload: Mapping[str, object] | None, + ) -> ComparisonPlotDraft: + visualization = (self._capabilities or {}).get("visualization") + categorical = ( + visualization.get("categorical_values") if isinstance(visualization, Mapping) else None + ) + categorical_values = ( + {str(field): _string_tuple(values) for field, values in categorical.items()} + if isinstance(categorical, Mapping) + else {} + ) + return ComparisonPlotDraft( + selected_models=self._selected_models, + reference_model=self._reference_model, + fluids=self.dataset_draft.selected_fluid_values(), + properties=self.dataset_draft.selected_property_values(), + fluid_aliases=self._fluid_aliases(), + categorical_values=categorical_values, + payload=payload, + parent=self, + ) + + def _discard_active_comparison(self) -> None: + if self._active_comparison is not None: + self._active_comparison.deleteLater() + self._active_comparison = None + self._active_comparison_row = -1 + + def _validated_payload( + self, + comparisons: tuple[dict[str, Any], ...], + ) -> dict[str, Any]: + from carnopy.config.sweep import ModelSweepConfig + + dataset = self.dataset_draft.dataset_payload() + result = copy.deepcopy(dataset) + result["document_type"] = "model_sweep" + result["backend"] = { + "name": "coolprop", + "models": list(self._selected_models), + "reference_model": self._reference_model, + } + if comparisons: + result["comparison_plots"] = { + "format": self._comparison_format, + "plots": copy.deepcopy(list(comparisons)), + } + try: + model = ModelSweepConfig.model_validate(result) + except ValidationError as exc: + raise ValueError(str(exc)) from exc + return model.model_dump(mode="json", by_alias=True, exclude_none=True) + + def _comparison_context_issue(self) -> tuple[int, str] | None: + for row, comparison in enumerate(self._comparisons): + if issue := self._comparison_value_issue(comparison): + return row, issue + return None + + def _comparison_value_issue(self, value: Mapping[str, object]) -> str: + name = str(value.get("name", "")) + fluid = str(value.get("fluid", "")) + if not self._fluid_selected(fluid): + return f"Comparison plot {name!r} uses an unselected fluid: {fluid}." + property_name = str(value.get("property", "")) + if property_name not in self.dataset_draft.selected_property_values(): + return f"Comparison plot {name!r} uses an unselected property: {property_name}." + raw_models = value.get("models") + models = _string_tuple(raw_models) + missing = [model for model in models if model not in self._selected_models] + if missing: + return ( + f"Comparison plot {name!r} selects models outside the sweep: " + + ", ".join(missing) + + "." + ) + if value.get("kind") == "property_delta" and self._reference_model in models: + return ( + f"Comparison plot {name!r} cannot select reference model " + f"{self._reference_model!r} for a delta." + ) + filters = value.get("filters") + if isinstance(filters, Mapping): + unavailable = [str(field) for field in filters if field not in COMPARISON_FILTER_FIELDS] + if unavailable: + return ( + f"Comparison plot {name!r} uses unavailable filter fields: " + + ", ".join(unavailable) + + "." + ) + return "" + + def _fluid_selected(self, value: str) -> bool: + aliases = self._fluid_aliases() + requested = aliases.get(value.casefold(), value).casefold() + return any( + aliases.get(selected.casefold(), selected).casefold() == requested + for selected in self.dataset_draft.selected_fluid_values() + ) + + def _fluid_aliases(self) -> dict[str, str]: + aliases: dict[str, str] = {} + fluids = (self._capabilities or {}).get("fluids") + if not isinstance(fluids, list): + return aliases + for entry in fluids: + if not isinstance(entry, Mapping): + continue + canonical = str(entry.get("name", "")) + for value in (canonical, *_string_tuple(entry.get("aliases"))): + if value: + aliases[value.casefold()] = canonical + return aliases + + def _unsupported_properties(self) -> list[tuple[str, list[str]]]: + capabilities = self._capabilities or {} + catalog = capabilities.get("property_catalog") + if not isinstance(catalog, list): + return [] + support = { + str(item.get("name")): set(_string_tuple(item.get("supported_models"))) + for item in catalog + if isinstance(item, Mapping) + } + result: list[tuple[str, list[str]]] = [] + for name in self.dataset_draft.selected_property_values(): + missing = [ + model for model in self._selected_models if model not in support.get(name, set()) + ] + if missing: + result.append((name, missing)) + return result + + def _dataset_changed(self) -> None: + if not self._loading: + self._state_changed() + + def _state_changed(self) -> None: + self._refresh_models() + self.validity_changed.emit() + self.dirty_changed.emit() + self.changed.emit() + + def _refresh_models(self) -> None: + unavailable_selected = ( + model for model in self._selected_models if model not in self._models + ) + visible_models = (*self._models, *unavailable_selected) + self.model_choices.replace( + DraftItem( + value=model, + display=MODEL_DISPLAY_NAMES.get(model, model.upper()), + canonical=model, + compatible=model in self._models, + selected=model in self._selected_models, + issue=( + "Unavailable in the current environment." + if model not in self._models + else "Reference model" + if model == self._reference_model + else "" + ), + ) + for model in visible_models + ) + self.comparison_plots_model.replace( + DraftItem( + value=str(item.get("name", "")), + display=_comparison_summary(item, default_format=self._comparison_format), + canonical=str(item.get("name", "")), + compatible=not bool(self._comparison_value_issue(item)), + issue=self._comparison_value_issue(item), + ) + for item in self._comparisons + ) + + +def _string_tuple(value: object) -> tuple[str, ...]: + if not isinstance(value, (list, tuple)): + return () + return tuple(str(item) for item in value) + + +def _permissive_dataset_capabilities( + payload: Mapping[str, object], + *, + reference_model: str, + current: Mapping[str, object] | None, +) -> dict[str, object]: + """Let the Dataset draft retain schema-valid imported values before rechecking them.""" + + result: dict[str, object] = copy.deepcopy(dict(current or {})) + result["model"] = reference_model + result["models"] = list(dict.fromkeys((*_string_tuple(result.get("models")), reference_model))) + mode = str(payload.get("mode", "")) + result["modes"] = list(dict.fromkeys((*_string_tuple(result.get("modes")), mode))) + + grid = payload.get("grid") + units = result.get("units_by_axis") + units_by_axis = ( + {str(axis): list(_string_tuple(values)) for axis, values in units.items()} + if isinstance(units, Mapping) + else {} + ) + if isinstance(grid, Mapping): + for axis, sampler in grid.items(): + if not isinstance(sampler, Mapping): + continue + unit = str(sampler.get("unit", "")) + existing = units_by_axis.setdefault(str(axis), []) + if unit and unit not in existing: + existing.append(unit) + result["units_by_axis"] = units_by_axis + + outputs = payload.get("outputs") + selected_formats = ( + _string_tuple(outputs.get("dataset_formats")) if isinstance(outputs, Mapping) else () + ) + result["dataset_formats"] = list( + dict.fromkeys((*_string_tuple(result.get("dataset_formats")), *selected_formats)) + ) + + fluids = result.get("fluids") + fluid_entries = ( + [copy.deepcopy(dict(entry)) for entry in fluids if isinstance(entry, Mapping)] + if isinstance(fluids, list) + else [] + ) + for selected in _string_tuple(payload.get("fluids")): + entry = _find_fluid_entry(fluid_entries, selected) + if entry is None: + entry = {"name": selected, "aliases": []} + fluid_entries.append(entry) + entry["supported_models"] = list( + dict.fromkeys((*_string_tuple(entry.get("supported_models")), reference_model)) + ) + result["fluids"] = fluid_entries + + property_catalog = result.get("property_catalog") + property_entries = ( + [copy.deepcopy(dict(entry)) for entry in property_catalog if isinstance(entry, Mapping)] + if isinstance(property_catalog, list) + else [] + ) + properties_by_name = {str(entry.get("name", "")): entry for entry in property_entries} + for selected in _string_tuple(payload.get("properties")): + entry = properties_by_name.get(selected) + if entry is None: + entry = {"name": selected} + property_entries.append(entry) + entry["supported_models"] = list( + dict.fromkeys((*_string_tuple(entry.get("supported_models")), reference_model)) + ) + result["property_catalog"] = property_entries + result.setdefault("reference_dependent_fields", []) + result.setdefault("reference_state", {}) + return result + + +def _find_fluid_entry( + entries: list[dict[str, object]], + selected: str, +) -> dict[str, object] | None: + folded = selected.casefold() + for entry in entries: + names = (str(entry.get("name", "")), *_string_tuple(entry.get("aliases"))) + if any(name.casefold() == folded for name in names): + return entry + return None + + +def _comparison_summary( + value: Mapping[str, object], + *, + default_format: str, +) -> str: + kind = str(value.get("kind", "property_comparison")) + kind_text = "Property delta" if kind == "property_delta" else "Property comparison" + property_name = str(value.get("property", "property")).replace("_", " ") + fluid = str(value.get("fluid", "fluid")) + models = value.get("models") + model_text = "all selected models" + if isinstance(models, list): + model_text = ( + ", ".join(str(item).upper() for item in models) + or "all selected models (empty explicit list)" + ) + parts = [kind_text, property_name, fluid, model_text] + if kind == "property_delta": + metric = str(value.get("delta_metric", "signed_relative_difference")) + parts.append(metric.replace("signed_", "").replace("_", " ")) + output_format = str(value.get("format", default_format)) + parts.append(output_format.upper()) + return " · ".join(parts) diff --git a/src/carnopy/app/worker.py b/src/carnopy/app/worker.py index 3a1b166..021d678 100644 --- a/src/carnopy/app/worker.py +++ b/src/carnopy/app/worker.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError from carnopy._execution import ExecutionCancelled, ExecutionControl +from carnopy.app.config_document import DocumentType from carnopy.app.protocol import ( ErrorCategory, EventType, @@ -68,6 +69,10 @@ class ValidateDatasetTextPayload(BaseModel): source_name: str = "" +class ValidateConfigurationTextPayload(ValidateDatasetTextPayload): + expected_document_type: DocumentType + + class GeneratePayload(ExecutionConfigPayload): output_root: Path figures_root: Path = Path("figures") @@ -196,6 +201,27 @@ def _execute( capabilities = CapabilitiesPayload.model_validate(request.payload) return cast(dict[str, Any], describe_capabilities(capabilities.model)) + if request.type == "load_configuration": + load_payload = ValidatePayload.model_validate(request.payload) + writer.emit("phase", {"name": "validation", "cancellable": True}) + try: + raw_bytes = load_payload.config_path.read_bytes() + except OSError as exc: + raise ConfigError( + f"could not read configuration {load_payload.config_path}: {exc}" + ) from exc + return _validated_configuration_payload( + raw_bytes, + source_name=str(load_payload.config_path), + ) + if request.type == "validate_configuration": + configuration_payload = ValidateConfigurationTextPayload.model_validate(request.payload) + writer.emit("phase", {"name": "validation", "cancellable": True}) + return _validated_configuration_payload( + configuration_payload.yaml_text.encode("utf-8"), + source_name=configuration_payload.source_name, + expected_document_type=configuration_payload.expected_document_type, + ) if request.type in WORKFLOW_REQUESTS: return execute_workflow_request( request.type, @@ -307,6 +333,61 @@ def _validated_dataset_payload(loaded: LoadedConfig) -> dict[str, Any]: } +def _validated_configuration_payload( + raw_bytes: bytes, + *, + source_name: str, + expected_document_type: DocumentType | None = None, +) -> dict[str, Any]: + """Validate one desktop configuration selected by its discriminator.""" + + from carnopy.config.io import ( + _load_config_payload, + _load_sweep_config_payload, + _parse_yaml_mapping, + ) + + config_path = Path(source_name) + payload = _parse_yaml_mapping(config_path, raw_bytes) + raw_document_type = payload.get("document_type") + if raw_document_type not in {"dataset", "model_sweep", "preparation"}: + if payload.get("schema_version") == 1 and raw_document_type is None: + raise ConfigError( + "configuration schema version 1 is no longer supported. Migrate to " + "schema_version: 2, add document_type: dataset, and replace " + "`backend: coolprop` with `backend: {name: coolprop, model: heos}`" + ) + raise ConfigError( + "configuration document_type must be dataset, model_sweep, or preparation" + ) + document_type = cast(DocumentType, raw_document_type) + if expected_document_type is not None and document_type != expected_document_type: + raise ConfigError( + f"expected a {expected_document_type} configuration, found {document_type}" + ) + + if document_type == "dataset": + loaded_dataset = _load_config_payload(config_path, raw_bytes, payload) + return { + "document_type": document_type, + **_validated_dataset_payload(loaded_dataset), + } + if document_type == "model_sweep": + loaded_configuration: Any = _load_sweep_config_payload(config_path, raw_bytes, payload) + else: + from carnopy.preparation.models import _load_preparation_config_payload + + loaded_configuration = _load_preparation_config_payload(config_path, raw_bytes, payload) + return { + "document_type": document_type, + "config": loaded_configuration.model.model_dump( + mode="json", by_alias=True, exclude_none=True + ), + "source_name": str(loaded_configuration.path), + "source_sha256": hashlib.sha256(loaded_configuration.raw_bytes).hexdigest(), + } + + def _config_error_payload(error: ConfigError) -> dict[str, Any]: details: dict[str, Any] | None = None cause: BaseException | None = error.__cause__ diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index f01b9f9..2df9451 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -2,12 +2,20 @@ import copy import hashlib +import json +from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast -from PySide6.QtCore import QObject, Signal +from PySide6.QtCore import Property, QObject, Signal -from carnopy.app.config_document import SavedConfigSnapshot, is_path_within, source_matches +from carnopy.app.config_document import ( + ConfigDocumentError, + DocumentType, + SavedConfigSnapshot, + is_path_within, + source_matches, +) from carnopy.app.inspection_controller import InspectionController from carnopy.app.jobs import JobStore from carnopy.app.protocol import RequestType, WorkerEvent @@ -18,9 +26,65 @@ RequestReservation, RequestSession, ) +from carnopy.app.workflow_models import ( + PreparationPlanModel, + PreparationPlanProjection, + WorkflowIssue, + WorkflowIssueModel, +) from carnopy.app.workspace import Workspace +if TYPE_CHECKING: + from carnopy.app.config_controller import ConfigurationController + WorkflowKind = Literal["sweep", "preparation"] +ResultRelation = Literal["unavailable", "current", "stale", "unrelated"] + + +@dataclass(frozen=True) +class _WorkflowRequestContext: + operation: str + workspace_root: Path | None + requested_path: Path | None + snapshot_path: Path | None + snapshot_sha256: str + plan_context_json: str + + +@dataclass(frozen=True) +class _PreparationSourceBinding: + source_path: Path + inspection_revision: str + descriptor_json: str + profile_json: str + + @classmethod + def create( + cls, + source_path: Path, + inspection_revision: str, + descriptor: dict[str, Any], + profile: dict[str, Any], + ) -> _PreparationSourceBinding: + return cls( + source_path=source_path.resolve(), + inspection_revision=inspection_revision, + descriptor_json=_canonical_mapping_json(descriptor), + profile_json=_canonical_mapping_json(profile), + ) + + def descriptor(self) -> dict[str, Any]: + return _mapping_from_json(self.descriptor_json) + + def profile(self) -> dict[str, Any]: + return _mapping_from_json(self.profile_json) + + def plan_context(self) -> dict[str, object]: + return { + "source_path": str(self.source_path), + "inspection_revision": self.inspection_revision, + "inspection_descriptor": self.descriptor(), + } class WorkflowController(QObject): @@ -35,15 +99,18 @@ def __init__( coordinator: DesktopRequestCoordinator, *, kind: WorkflowKind, + configuration_controller: ConfigurationController | None = None, parent: QObject | None = None, ) -> None: super().__init__(parent) self.coordinator = coordinator self.kind = kind + self.configuration_controller = configuration_controller self.owner: RequestOwner = kind self.workspace: Workspace | None = None self._store: JobStore | None = None self._session: RequestSession | None = None + self._request_context: _WorkflowRequestContext | None = None self._operation = "" self._state = "unavailable" self._phase = "" @@ -55,11 +122,21 @@ def __init__( self._validation: dict[str, Any] | None = None self._plan: dict[str, Any] | None = None self._plan_config_sha256 = "" + self._planned_context: dict[str, object] | None = None + self._plan_stale_reason = "" self._result: dict[str, Any] | None = None + self._result_config_sha256 = "" + self._result_context: dict[str, object] | None = None self._activity_persistence_issue = "" self._active_snapshot: SavedConfigSnapshot | None = None + self._active_plan_context: dict[str, object] | None = None self._active_record: dict[str, Any] | None = None + self.plan_blocking_reasons = WorkflowIssueModel(self) + self.execution_blocking_reasons = WorkflowIssueModel(self) coordinator.busy_changed.connect(lambda _busy: self.state_changed.emit()) + if configuration_controller is not None: + configuration_controller.state_changed.connect(self.state_changed.emit) + self.state_changed.connect(self._refresh_typed_projections) @property def state(self) -> str: @@ -101,42 +178,373 @@ def validation(self) -> dict[str, Any] | None: def current_plan(self) -> dict[str, Any] | None: return copy.deepcopy(self._plan) + def get_has_plan(self) -> bool: + return self._plan is not None + + hasPlan = Property(bool, get_has_plan, notify=state_changed) + + def get_plan_id(self) -> str: + return _text((self._plan or {}).get("plan_id")) + + planId = Property(str, get_plan_id, notify=state_changed) + + @property + def plan_current(self) -> bool: + if self._plan is None or self._plan_stale_reason: + return False + try: + snapshot = self._saved_snapshot() + except ValueError: + return False + return snapshot.sha256 == self._plan_config_sha256 and self._plan_context_matches() + + def get_plan_current(self) -> bool: + return self.plan_current + + planCurrent = Property(bool, get_plan_current, notify=state_changed) + @property def result(self) -> dict[str, Any] | None: return copy.deepcopy(self._result) + def get_has_result(self) -> bool: + return self._result is not None + + hasResult = Property(bool, get_has_result, notify=state_changed) + + def get_result_output_directory(self) -> str: + return _text((self._result or {}).get("output_directory")) + + resultOutputDirectory = Property( + str, + get_result_output_directory, + notify=state_changed, + ) + + def get_result_relation(self) -> ResultRelation: + if self._result is None: + return "unavailable" + controller = self.configuration_controller + if ( + controller is not None + and controller.get_document_kind() != self._expected_document_type() + ): + return "unrelated" + try: + snapshot = self._saved_snapshot() + except ValueError: + return "stale" + if snapshot.sha256 != self._result_config_sha256: + return "stale" + if not self._context_matches(self._result_context): + return "stale" + return "current" + + resultRelation = Property(str, get_result_relation, notify=state_changed) + @property def activity_persistence_issue(self) -> str: return self._activity_persistence_issue + def get_workflow_kind(self) -> str: + return self.kind + + workflowKind = Property(str, get_workflow_kind, constant=True) + + def get_document_kind(self) -> str: + return self._expected_document_type() + + documentKind = Property(str, get_document_kind, constant=True) + + def get_workflow_state(self) -> str: + return self._state + + workflowState = Property(str, get_workflow_state, notify=state_changed) + + def get_workflow_operation(self) -> str: + return self._operation + + workflowOperation = Property(str, get_workflow_operation, notify=state_changed) + + def get_workflow_phase(self) -> str: + return self._phase + + workflowPhase = Property(str, get_workflow_phase, notify=state_changed) + + def get_operation_active(self) -> bool: + return self._session is not None + + operationActive = Property(bool, get_operation_active, notify=state_changed) + + def get_progress_available(self) -> bool: + return bool(self._progress) + + progressAvailable = Property(bool, get_progress_available, notify=state_changed) + + def get_progress_completed(self) -> int: + return _nonnegative_int(self._progress.get("completed")) + + progressCompleted = Property(int, get_progress_completed, notify=state_changed) + + def get_progress_total(self) -> int: + return _nonnegative_int(self._progress.get("total")) + + progressTotal = Property(int, get_progress_total, notify=state_changed) + + def get_failure_category(self) -> str: + return _text(self._failure.get("category")) + + failureCategory = Property(str, get_failure_category, notify=state_changed) + + def get_failure_code(self) -> str: + return _text(self._failure.get("code")) + + failureCode = Property(str, get_failure_code, notify=state_changed) + + def get_failure_message(self) -> str: + return _text(self._failure.get("message")) + + failureMessage = Property(str, get_failure_message, notify=state_changed) + + def get_activity_persistence_issue(self) -> str: + return self._activity_persistence_issue + + activityPersistenceIssue = Property( + str, + get_activity_persistence_issue, + notify=state_changed, + ) + + def get_plan_blocking_reasons(self) -> QObject: + return self.plan_blocking_reasons + + planBlockingReasons = Property(QObject, get_plan_blocking_reasons, constant=True) + + def get_execution_blocking_reasons(self) -> QObject: + return self.execution_blocking_reasons + + executionBlockingReasons = Property( + QObject, + get_execution_blocking_reasons, + constant=True, + ) + @property def can_cancel(self) -> bool: return self._session is not None and self._session.cooperative_cancel_available + def get_cancellation_available(self) -> bool: + return self.can_cancel + + cancellationAvailable = Property(bool, get_cancellation_available, notify=state_changed) + @property def can_force_stop(self) -> bool: return self._session is not None and self._session.force_stop_available + def get_force_stop_available(self) -> bool: + return self.can_force_stop + + forceStopAvailable = Property(bool, get_force_stop_available, notify=state_changed) + + def get_protected_finalization(self) -> bool: + return self._session is not None and self._session.termination_protected + + protectedFinalization = Property(bool, get_protected_finalization, notify=state_changed) + @property def can_plan(self) -> bool: - try: - self._saved_snapshot() - self._plan_context() - except ValueError: - return False - return self._session is None and not self.coordinator.is_busy + return not self._plan_blocking_issues() + + def get_can_plan(self) -> bool: + return self.can_plan + + canPlan = Property(bool, get_can_plan, notify=state_changed) @property def can_execute(self) -> bool: - if self._plan is None or self._plan_config_sha256 != self._config_sha256: - return False - if not self._plan_context_matches(): - return False + return not self._execution_blocking_issues() + + def get_can_execute(self) -> bool: + return self.can_execute + + canExecute = Property(bool, get_can_execute, notify=state_changed) + + def _plan_blocking_issues(self) -> tuple[WorkflowIssue, ...]: + issues: list[WorkflowIssue] = [] + if issue := self._saved_configuration_issue(): + issues.append(issue) + try: + self._plan_context() + except ValueError as exc: + issues.append( + self._blocking_issue( + origin="source" if self.kind == "preparation" else "local", + code=( + "preparation_source_unavailable" + if self.kind == "preparation" + else "plan_context_unavailable" + ), + message=str(exc), + section="source" if self.kind == "preparation" else "plan", + field_id=( + "preparation.source" + if self.kind == "preparation" + else f"{self.kind}.configuration" + ), + ) + ) + issues.extend(self._workflow_input_issues()) + if issue := self._worker_availability_issue(): + issues.append(issue) + return tuple(issues) + + def _execution_blocking_issues(self) -> tuple[WorkflowIssue, ...]: + issues: list[WorkflowIssue] = [] + if self._plan is None: + issues.append( + self._blocking_issue( + origin="plan", + code="current_plan_required", + message="Create a current plan before execution.", + section="plan", + field_id=f"{self.kind}.plan", + ) + ) + elif self._plan_stale_reason: + issues.append( + self._blocking_issue( + origin="plan", + code="plan_rejected_by_worker", + message=self._plan_stale_reason, + section="plan", + field_id=f"{self.kind}.plan", + ) + ) + elif not self._plan_configuration_matches(): + issues.append( + self._blocking_issue( + origin="plan", + code="plan_configuration_changed", + message="The plan belongs to a different saved configuration.", + section="plan", + field_id=f"{self.kind}.plan", + ) + ) + elif not self._plan_context_matches(): + issues.append( + self._blocking_issue( + origin="source" if self.kind == "preparation" else "plan", + code="plan_context_changed", + message="The plan no longer matches the current workflow context.", + section="source" if self.kind == "preparation" else "plan", + field_id=( + "preparation.source" if self.kind == "preparation" else f"{self.kind}.plan" + ), + ) + ) + if issue := self._saved_configuration_issue(): + issues.append(issue) + issues.extend(self._workflow_input_issues()) + if issue := self._worker_availability_issue(): + issues.append(issue) + return tuple(issues) + + def _workflow_input_issues(self) -> tuple[WorkflowIssue, ...]: + return () + + def _saved_configuration_issue(self) -> WorkflowIssue | None: + if self.workspace is None: + return self._blocking_issue( + origin="local", + code="workspace_required", + message="Open a workspace first.", + section="workspace", + field_id="workspace", + ) + controller = self.configuration_controller + if controller is not None: + if controller.get_document_kind() != self._expected_document_type(): + return self._blocking_issue( + origin="local", + code="saved_configuration_required", + message=f"Open and save a {self.kind} configuration first.", + section="configuration", + field_id=f"{self.kind}.configuration", + ) + try: + self._saved_snapshot() + except ValueError as exc: + return self._blocking_issue( + origin="local", + code="saved_configuration_unavailable", + message=str(exc), + section="configuration", + field_id=f"{self.kind}.configuration", + ) + return None + if self._config_path is None or not self._config_sha256: + return self._blocking_issue( + origin="local", + code="saved_configuration_required", + message=f"Load a {self.kind} configuration first.", + section="configuration", + field_id=f"{self.kind}.configuration", + ) try: self._saved_snapshot() - except ValueError: - return False - return self._session is None and not self.coordinator.is_busy + except ValueError as exc: + return self._blocking_issue( + origin="local", + code="saved_configuration_unavailable", + message=str(exc), + section="configuration", + field_id=f"{self.kind}.configuration", + ) + return None + + def _worker_availability_issue(self) -> WorkflowIssue | None: + if self._session is not None: + operation = self._operation.replace("_", " ") or "workflow" + return self._blocking_issue( + origin="runtime", + code="operation_active", + message=f"The {operation} operation is already active.", + section="operation", + field_id=f"{self.kind}.operation", + ) + if self.coordinator.is_busy: + return self._blocking_issue( + origin="runtime", + code="desktop_worker_busy", + message="Another desktop worker operation is active.", + section="operation", + field_id=f"{self.kind}.operation", + ) + return None + + def _blocking_issue( + self, + *, + origin: Literal["local", "source", "dependency", "plan", "runtime"], + code: str, + message: str, + section: str, + field_id: str, + ) -> WorkflowIssue: + return WorkflowIssue( + origin=origin, + severity="blocking", + code=code, + message=message, + document_kind=self.get_document_kind(), + section=section, + field_id=field_id, + ) + + def _refresh_typed_projections(self) -> None: + self.plan_blocking_reasons.replace(self._plan_blocking_issues()) + self.execution_blocking_reasons.replace(self._execution_blocking_issues()) def set_workspace(self, workspace: Workspace | None) -> None: if workspace == self.workspace: @@ -152,8 +560,8 @@ def set_workspace(self, workspace: Workspace | None) -> None: self._config_path = None self._config_sha256 = "" self._validation = None - self._result = None - self._invalidate_plan("workspace changed") + self._clear_result() + self._clear_plan() self._set_activity_persistence_issue("") self._state = "ready" if workspace is not None else "unavailable" self.state_changed.emit() @@ -179,6 +587,9 @@ def plan(self) -> bool: except ValueError as exc: self._set_local_failure("request", "plan_unavailable", str(exc)) return False + if issues := self._workflow_input_issues(): + self._set_local_failure("request", "plan_unavailable", issues[0].message) + return False workspace = self.workspace if workspace is None: return False @@ -261,23 +672,26 @@ def _start( self._phase = "" self._progress = {} self._failure = {} - self._result = None self._active_record = None self._active_snapshot = snapshot + self._active_plan_context = ( + copy.deepcopy(self._planned_context) if persist_execution else None + ) + request_context = self._capture_request_context(operation, payload, snapshot) reservation: RequestReservation try: reservation = self.coordinator.reserve_request(self.owner, request_type) except (RuntimeError, ValueError) as exc: + self._clear_active_attempt() self._set_local_failure("request", "request_unavailable", str(exc)) return False - if operation in {"load", "plan"}: - self._invalidate_plan(f"{operation} started") self._set_activity_persistence_issue("") if persist_execution: try: self._start_activity(reservation, snapshot) except (OSError, UnicodeError, ValueError) as exc: self.coordinator.abandon_reserved_request(reservation) + self._clear_active_attempt() self._set_activity_persistence_issue( f"could not persist workflow Activity record: {exc}" ) @@ -292,11 +706,13 @@ def _start( except Exception as exc: self.coordinator.abandon_reserved_request(reservation) self._finish_start_failure(reservation, exc) + self._clear_active_attempt() if operation == "load": self._clear_loaded_configuration() self._set_local_failure("process", "worker_start_failed", str(exc)) return False self._session = session + self._request_context = request_context session.event_received.connect(self._event_received) session.state_changed.connect(self._session_state_changed) session.policy_changed.connect(self.state_changed) @@ -353,6 +769,15 @@ def _request_completed(self, value: object) -> None: session = self._session if session is None or outcome.request_id != session.request_id: return + context = self._request_context + self._request_context = None + if context is None or not self._response_context_is_current(context): + if self._active_record is not None: + self._finish_activity(outcome) + self._session = None + self._clear_active_attempt() + self.state_changed.emit() + return if self._active_record is not None: self._finish_activity(outcome) result = outcome.result_payload @@ -362,8 +787,11 @@ def _request_completed(self, value: object) -> None: code = str(self._failure.get("code", "execution_failed")) if operation == "load": self._clear_loaded_configuration() - if operation in {"load", "plan"} or code in {"source_changed", "stale_plan"}: - self._invalidate_plan(code) + if code in {"source_changed", "stale_plan"}: + message = _text(self._failure.get("message")) + self._mark_plan_stale( + message or "The worker rejected the previously accepted plan." + ) self._state = ( "cancelled" if code == "cancelled" @@ -372,8 +800,13 @@ def _request_completed(self, value: object) -> None: else "failed" ) elif operation == "load": - self._accept_loaded(result) - self._state = "ready" + try: + self._accept_loaded(result, requested_path=context.requested_path) + except ValueError as exc: + self._clear_loaded_configuration() + self._set_local_failure("request", "invalid_load_result", str(exc)) + else: + self._state = "ready" elif operation == "validate": self._validation = copy.deepcopy(result) self._state = "validated" if result.get("valid", True) else "invalid" @@ -381,22 +814,28 @@ def _request_completed(self, value: object) -> None: try: self._accept_plan(result) except ValueError as exc: - self._invalidate_plan("plan result no longer matches current inputs") self._set_local_failure("request", "stale_plan", str(exc)) else: self._state = "planned" else: self._result = copy.deepcopy(result) + active_snapshot = self._active_snapshot + self._result_config_sha256 = "" if active_snapshot is None else active_snapshot.sha256 + self._result_context = copy.deepcopy(self._active_plan_context) self._state = "succeeded" output = result.get("output_directory") if isinstance(output, str) and output: self.output_finalized.emit(Path(output)) self._session = None - self._active_snapshot = None - self._active_record = None + self._clear_active_attempt() self.state_changed.emit() - def _accept_loaded(self, result: dict[str, object]) -> None: + def _accept_loaded( + self, + result: dict[str, object], + *, + requested_path: Path | None, + ) -> None: source = result.get("source_name") digest = result.get("source_sha256") config = result.get("config") @@ -406,24 +845,81 @@ def _accept_loaded(self, result: dict[str, object]) -> None: or not isinstance(config, dict) ): raise ValueError("workflow load result is missing configuration identity") + source_path = Path(source).expanduser().resolve() + if requested_path is None or source_path != requested_path: + raise ValueError("workflow load result does not match its requested source") self._loaded_config = copy.deepcopy(config) - self._config_path = Path(source).expanduser().resolve() + self._config_path = source_path self._config_sha256 = digest self._validation = None - self._invalidate_plan("configuration loaded") + + def _capture_request_context( + self, + operation: str, + payload: dict[str, object], + snapshot: SavedConfigSnapshot | None, + ) -> _WorkflowRequestContext: + requested = payload.get("config_path") + requested_path = ( + Path(requested).expanduser().resolve() if isinstance(requested, str) else None + ) + plan_context = self._active_plan_context + return _WorkflowRequestContext( + operation=operation, + workspace_root=None if self.workspace is None else self.workspace.root, + requested_path=requested_path, + snapshot_path=None if snapshot is None else snapshot.path, + snapshot_sha256="" if snapshot is None else snapshot.sha256, + plan_context_json=( + "" if plan_context is None else _canonical_mapping_json(plan_context) + ), + ) + + def _response_context_is_current(self, context: _WorkflowRequestContext) -> bool: + workspace_root = None if self.workspace is None else self.workspace.root + if workspace_root != context.workspace_root or self._operation != context.operation: + return False + snapshot = self._active_snapshot + if snapshot is None: + if context.snapshot_path is not None or context.snapshot_sha256: + return False + elif snapshot.path != context.snapshot_path or snapshot.sha256 != context.snapshot_sha256: + return False + plan_context = self._active_plan_context + current_plan_context_json = ( + "" if plan_context is None else _canonical_mapping_json(plan_context) + ) + return current_plan_context_json == context.plan_context_json def _accept_plan(self, result: dict[str, object]) -> None: plan_id = result.get("plan_id") config_sha = result.get("configuration_sha256") - if not isinstance(plan_id, str) or config_sha != self._config_sha256: + active_snapshot = self._active_snapshot + if ( + not isinstance(plan_id, str) + or active_snapshot is None + or config_sha != active_snapshot.sha256 + ): raise ValueError("workflow plan result does not match the loaded configuration") + current_snapshot = self._saved_snapshot() + if current_snapshot.sha256 != active_snapshot.sha256: + raise ValueError("workflow plan result no longer matches the current configuration") if not self._plan_result_matches_current_context(result): raise ValueError("workflow plan result no longer matches the current inputs") self._plan = copy.deepcopy(result) - self._plan_config_sha256 = self._config_sha256 + self._plan_config_sha256 = active_snapshot.sha256 self._record_plan_context() + self._plan_stale_reason = "" def _saved_snapshot(self) -> SavedConfigSnapshot: + controller = self.configuration_controller + if controller is not None: + try: + return controller.execution_snapshot( + expected_document_type=self._expected_document_type() + ) + except ConfigDocumentError as exc: + raise ValueError(str(exc)) from exc workspace = self.workspace path = self._config_path digest = self._config_sha256 @@ -440,16 +936,42 @@ def _saved_snapshot(self) -> SavedConfigSnapshot: yaml_bytes = path.read_bytes() if hashlib.sha256(yaml_bytes).hexdigest() != digest: raise ValueError("saved workflow configuration changed; load it again") - return SavedConfigSnapshot(path=path, yaml_bytes=yaml_bytes, sha256=digest) + return SavedConfigSnapshot( + path=path, + yaml_bytes=yaml_bytes, + sha256=digest, + document_type=self._expected_document_type(), + ) + + def _expected_document_type(self) -> DocumentType: + return "model_sweep" if self.kind == "sweep" else "preparation" + + def _plan_configuration_matches(self) -> bool: + if self.configuration_controller is None: + return self._plan_config_sha256 == self._config_sha256 + try: + snapshot = self._saved_snapshot() + except ValueError: + return False + return snapshot.sha256 == self._plan_config_sha256 def _plan_context(self) -> dict[str, object]: return {} def _record_plan_context(self) -> None: - pass + self._planned_context = copy.deepcopy(self._plan_context()) def _plan_context_matches(self) -> bool: - return True + return self._context_matches(self._planned_context) + + def _context_matches(self, expected: dict[str, object] | None) -> bool: + if expected is None: + return False + try: + current_context = self._plan_context() + except ValueError: + return False + return current_context == expected def _plan_result_matches_current_context(self, _result: dict[str, object]) -> bool: return True @@ -457,9 +979,26 @@ def _plan_result_matches_current_context(self, _result: dict[str, object]) -> bo def _activity_source_identity(self) -> dict[str, Any] | None: return None - def _invalidate_plan(self, _reason: str) -> None: + def _mark_plan_stale(self, reason: str) -> None: + if self._plan is not None: + self._plan_stale_reason = reason + + def _clear_plan(self) -> None: self._plan = None self._plan_config_sha256 = "" + self._planned_context = None + self._plan_stale_reason = "" + + def _clear_result(self) -> None: + self._result = None + self._result_config_sha256 = "" + self._result_context = None + + def _clear_active_attempt(self) -> None: + self._active_snapshot = None + self._active_plan_context = None + self._active_record = None + self._request_context = None def _clear_loaded_configuration(self) -> None: self._loaded_config = None @@ -549,70 +1088,299 @@ def __init__( self, coordinator: DesktopRequestCoordinator, parent: QObject | None = None, + *, + configuration_controller: ConfigurationController | None = None, ) -> None: - super().__init__(coordinator, kind="sweep", parent=parent) + super().__init__( + coordinator, + kind="sweep", + configuration_controller=configuration_controller, + parent=parent, + ) + self._refresh_typed_projections() class PreparationWorkflowController(WorkflowController): + source_binding_changed = Signal() + def __init__( self, coordinator: DesktopRequestCoordinator, inspection: InspectionController, parent: QObject | None = None, + *, + configuration_controller: ConfigurationController | None = None, ) -> None: - super().__init__(coordinator, kind="preparation", parent=parent) + super().__init__( + coordinator, + kind="preparation", + configuration_controller=configuration_controller, + parent=parent, + ) self.inspection = inspection - self._planned_inspection_revision = "" + self._source_binding: _PreparationSourceBinding | None = None + self._source_binding_issue = "" + self.preparation_plan_model = PreparationPlanModel(self) inspection.inspection_changed.connect(self._inspection_changed) + if configuration_controller is not None: + configuration_controller.preparation_draft.profile_changed.connect( + self.state_changed.emit + ) + configuration_controller.preparation_draft.capability_changed.connect( + self.state_changed.emit + ) + self._refresh_typed_projections() - def _plan_context(self) -> dict[str, object]: - snapshot = self.inspection.preparation_source_snapshot() - if snapshot is None: + def get_preparation_plan_model(self) -> QObject: + return self.preparation_plan_model + + preparationPlan = Property(QObject, get_preparation_plan_model, constant=True) + + def get_has_bound_source(self) -> bool: + return self._source_binding is not None + + hasBoundSource = Property(bool, get_has_bound_source, notify=source_binding_changed) + + def get_bound_source_path(self) -> str: + binding = self._source_binding + return "" if binding is None else str(binding.source_path) + + boundSourcePath = Property(str, get_bound_source_path, notify=source_binding_changed) + + def get_bound_source_kind(self) -> str: + binding = self._source_binding + if binding is None: + return "" + value = binding.profile().get("source_kind") + return value if isinstance(value, str) else "" + + boundSourceKind = Property(str, get_bound_source_kind, notify=source_binding_changed) + + def get_bound_source_revision(self) -> str: + binding = self._source_binding + return "" if binding is None else binding.inspection_revision + + boundSourceRevision = Property( + str, + get_bound_source_revision, + notify=source_binding_changed, + ) + + def get_source_binding_issue(self) -> str: + return self._source_binding_issue + + sourceBindingIssue = Property( + str, + get_source_binding_issue, + notify=source_binding_changed, + ) + + def get_inspected_source_matches_binding(self) -> bool: + candidate = self._inspected_source_binding() + return candidate is not None and candidate == self._source_binding + + inspectedSourceMatchesBinding = Property( + bool, + get_inspected_source_matches_binding, + notify=source_binding_changed, + ) + + def get_inspected_source_available(self) -> bool: + candidate = self._inspected_source_binding() + return candidate is not None and candidate != self._source_binding + + inspectedSourceAvailable = Property( + bool, + get_inspected_source_available, + notify=source_binding_changed, + ) + + def get_bound_source_refresh_available(self) -> bool: + binding = self._source_binding + candidate = self._inspected_source_binding() + return bool( + binding is not None + and candidate is not None + and candidate.source_path == binding.source_path + and candidate != binding + ) + + boundSourceRefreshAvailable = Property( + bool, + get_bound_source_refresh_available, + notify=source_binding_changed, + ) + + def bind_inspected_source(self) -> bool: + candidate = self._inspected_source_binding() + if candidate is None: reason = self.inspection.get_preparation_ineligible_reason() - raise ValueError(reason or "inspect an eligible preparation source first") - source, revision, descriptor = snapshot - return { - "source_path": str(source), - "inspection_revision": revision, - "inspection_descriptor": descriptor, - } + self._set_source_binding_issue( + reason or "Inspect an eligible source before using it for ML Preparation." + ) + return False + if candidate == self._source_binding: + self._set_source_binding_issue("") + return True + if self._source_binding_change_blocked(): + self._set_source_binding_issue( + "The Preparation source cannot change while a worker operation is active." + ) + return False + self._source_binding = candidate + self._set_source_binding_issue("") + self.source_binding_changed.emit() + self.state_changed.emit() + return True - def _record_plan_context(self) -> None: - snapshot = self.inspection.preparation_source_snapshot() - self._planned_inspection_revision = "" if snapshot is None else snapshot[1] + def clear_bound_source(self) -> bool: + if self._source_binding is None: + self._set_source_binding_issue("") + return True + if self._source_binding_change_blocked(): + self._set_source_binding_issue( + "The Preparation source cannot change while a worker operation is active." + ) + return False + self._source_binding = None + self._set_source_binding_issue("") + self.source_binding_changed.emit() + self.state_changed.emit() + return True - def _plan_context_matches(self) -> bool: - snapshot = self.inspection.preparation_source_snapshot() - return snapshot is not None and snapshot[1] == self._planned_inspection_revision + def bound_source_snapshot( + self, + ) -> tuple[Path, str, dict[str, Any], dict[str, Any]] | None: + binding = self._source_binding + if binding is None: + return None + return ( + binding.source_path, + binding.inspection_revision, + binding.descriptor(), + binding.profile(), + ) + + def set_workspace(self, workspace: Workspace | None) -> None: + changed = workspace != self.workspace + if changed: + self._source_binding = None + self._source_binding_issue = "" + super().set_workspace(workspace) + if changed: + self.source_binding_changed.emit() + + def _plan_context(self) -> dict[str, object]: + binding = self._source_binding + if binding is None: + raise ValueError("use an inspected source for ML Preparation first") + return binding.plan_context() + + def _workflow_input_issues(self) -> tuple[WorkflowIssue, ...]: + controller = self.configuration_controller + if controller is None or controller.get_document_kind() != "preparation": + return () + draft = controller.preparation_draft + issues: list[WorkflowIssue] = [] + if source_issue := draft.get_source_issue(): + issues.append( + self._blocking_issue( + origin="source", + code="preparation_source_incompatible", + message=source_issue, + section="source", + field_id="preparation.source", + ) + ) + if dependency_issue := draft.get_dependency_issue(): + issues.append( + self._blocking_issue( + origin="dependency", + code="preparation_dependency_unavailable", + message=dependency_issue, + section="outputs", + field_id="preparation.outputs", + ) + ) + return tuple(issues) + + def _accept_plan(self, result: dict[str, object]) -> None: + projection = PreparationPlanProjection.from_worker_payload(result) + super()._accept_plan(result) + self.preparation_plan_model.replace(projection) + + def _clear_plan(self) -> None: + super()._clear_plan() + self.preparation_plan_model.clear() def _plan_result_matches_current_context(self, result: dict[str, object]) -> bool: - snapshot = self.inspection.preparation_source_snapshot() + binding = self._source_binding source_revision = result.get("source_revision") - if snapshot is None or not isinstance(source_revision, dict): + if binding is None or not isinstance(source_revision, dict): return False - source, revision, descriptor = snapshot + descriptor = binding.descriptor() return ( - source_revision.get("inspection_revision") == revision + source_revision.get("inspection_revision") == binding.inspection_revision and source_revision.get("inspection_descriptor") == descriptor - and descriptor.get("source_path") == str(source) + and descriptor.get("source_path") == str(binding.source_path) ) def _activity_source_identity(self) -> dict[str, Any] | None: - snapshot = self.inspection.preparation_source_snapshot() - if snapshot is None: + binding = self._source_binding + if binding is None: return None - source, revision, descriptor = snapshot + profile = binding.profile() return { - "source_path": str(source), - "inspection_revision": revision, - "descriptor": descriptor, + "source_path": str(binding.source_path), + "source_kind": profile.get("source_kind"), + "inspection_revision": binding.inspection_revision, + "descriptor": binding.descriptor(), + "source_identity": copy.deepcopy(profile.get("source_identity")), } - def _invalidate_plan(self, reason: str) -> None: - super()._invalidate_plan(reason) - self._planned_inspection_revision = "" - def _inspection_changed(self, _payload: object) -> None: - if self._session is None or self._operation != "execute": - self._invalidate_plan("inspection changed") + self.source_binding_changed.emit() + self.state_changed.emit() + + def _inspected_source_binding(self) -> _PreparationSourceBinding | None: + source_snapshot = self.inspection.preparation_source_snapshot() + profile = self.inspection.preparation_profile_snapshot() + if source_snapshot is None or profile is None: + return None + source, revision, descriptor = source_snapshot + if ( + profile.get("source_path") != str(source) + or profile.get("inspection_revision") != revision + or profile.get("source_kind") != descriptor.get("source_kind") + ): + return None + return _PreparationSourceBinding.create(source, revision, descriptor, profile) + + def _source_binding_change_blocked(self) -> bool: + return self._session is not None or self.coordinator.is_busy + + def _set_source_binding_issue(self, issue: str) -> None: + if issue == self._source_binding_issue: + return + self._source_binding_issue = issue + self.source_binding_changed.emit() self.state_changed.emit() + + +def _text(value: object) -> str: + return value if isinstance(value, str) else "" + + +def _nonnegative_int(value: object) -> int: + return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0 + + +def _canonical_mapping_json(value: dict[str, Any]) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _mapping_from_json(value: str) -> dict[str, Any]: + decoded = json.loads(value) + if not isinstance(decoded, dict): # pragma: no cover - encoded only by this module + raise ValueError("Preparation source binding payload is not a mapping") + return cast(dict[str, Any], decoded) diff --git a/src/carnopy/app/workflow_models.py b/src/carnopy/app/workflow_models.py new file mode 100644 index 0000000..fd8855b --- /dev/null +++ b/src/carnopy/app/workflow_models.py @@ -0,0 +1,1071 @@ +from __future__ import annotations + +import copy +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from typing import Literal, cast + +from PySide6.QtCore import ( + Property, + QAbstractListModel, + QByteArray, + QModelIndex, + QObject, + QPersistentModelIndex, + Qt, + Signal, + Slot, +) + +IssueOrigin = Literal["local", "schema", "source", "dependency", "plan", "runtime"] +IssueSeverity = Literal["blocking", "advisory"] + +ORIGIN_ROLE = int(Qt.ItemDataRole.UserRole) + 1 +SEVERITY_ROLE = ORIGIN_ROLE + 1 +CODE_ROLE = ORIGIN_ROLE + 2 +MESSAGE_ROLE = ORIGIN_ROLE + 3 +DOCUMENT_KIND_ROLE = ORIGIN_ROLE + 4 +SECTION_ROLE = ORIGIN_ROLE + 5 +FIELD_ID_ROLE = ORIGIN_ROLE + 6 +ITEM_KEY_ROLE = ORIGIN_ROLE + 7 +NESTED_ROW_ROLE = ORIGIN_ROLE + 8 +PATH_ROLE = ORIGIN_ROLE + 9 +INVALID_INDEX = QModelIndex() + + +class WorkflowListModel(QAbstractListModel): + """Expose detached workflow rows through one explicit, stable role set.""" + + count_changed = Signal() + + def __init__(self, roles: Sequence[str], parent: QObject | None = None) -> None: + super().__init__(parent) + if not roles or len(set(roles)) != len(roles): + raise ValueError("workflow model roles must be non-empty and unique") + self._roles = tuple(roles) + self._role_names = { + int(Qt.ItemDataRole.UserRole) + offset: name + for offset, name in enumerate(self._roles, start=1) + } + self._rows: tuple[dict[str, object], ...] = () + + def roleNames(self) -> dict[int, QByteArray]: + return {role: QByteArray(name.encode("utf-8")) for role, name in self._role_names.items()} + + def rowCount( + self, + _parent: QModelIndex | QPersistentModelIndex = INVALID_INDEX, + ) -> int: + return len(self._rows) + + def data( + self, + index: QModelIndex | QPersistentModelIndex, + role: int = int(Qt.ItemDataRole.DisplayRole), + ) -> object: + if not index.isValid() or not 0 <= index.row() < len(self._rows): + return None + name = self._role_names.get(role) + return None if name is None else copy.deepcopy(self._rows[index.row()].get(name)) + + def replace(self, rows: Iterable[Mapping[str, object]]) -> bool: + updated = tuple( + {name: copy.deepcopy(row.get(name)) for name in self._roles} for row in rows + ) + if updated == self._rows: + return False + previous_count = len(self._rows) + self.beginResetModel() + self._rows = updated + self.endResetModel() + if len(updated) != previous_count: + self.count_changed.emit() + return True + + def clear(self) -> bool: + return self.replace(()) + + def get_count(self) -> int: + return len(self._rows) + + count = Property(int, get_count, notify=count_changed) + + @Slot(int, result="QVariantMap") + def get(self, row: int) -> dict[str, object]: + return copy.deepcopy(self._rows[row]) if 0 <= row < len(self._rows) else {} + + def rows(self) -> tuple[dict[str, object], ...]: + return tuple(copy.deepcopy(row) for row in self._rows) + + +@dataclass(frozen=True) +class PreparationPlanProjection: + """Validated, detached QML-facing rows from one worker Preparation plan.""" + + source_row_count: int + eligible_row_count: int + excluded_row_count: int + reference_context_required: bool + reference_context_compatible: bool + reference_policy: str + reference_backend: str + reference_backend_model: str + semantic_fields: tuple[dict[str, object], ...] + reference_fields: tuple[dict[str, object], ...] + reference_contexts: tuple[dict[str, object], ...] + exclusion_reasons: tuple[dict[str, object], ...] + categories: tuple[dict[str, object], ...] + scenarios: tuple[dict[str, object], ...] + partitions: tuple[dict[str, object], ...] + transformations: tuple[dict[str, object], ...] + leakage_audits: tuple[dict[str, object], ...] + output_formats: tuple[dict[str, object], ...] + array_scopes: tuple[dict[str, object], ...] + array_conversion_errors: tuple[dict[str, object], ...] + array_auxiliary_shapes: tuple[dict[str, object], ...] + matrix_checks: tuple[dict[str, object], ...] + baseline_checks: tuple[dict[str, object], ...] + baseline_estimators: tuple[dict[str, object], ...] + dependencies: tuple[dict[str, object], ...] + + @classmethod + def from_worker_payload( + cls, + payload: Mapping[str, object], + ) -> PreparationPlanProjection: + source_rows = _nonnegative_integer(payload.get("source_row_count"), "source row count") + eligible_rows = _nonnegative_integer( + payload.get("eligible_row_count"), "eligible row count" + ) + excluded_rows = _nonnegative_integer( + payload.get("excluded_row_count"), "excluded row count" + ) + if eligible_rows + excluded_rows != source_rows: + raise ValueError("Preparation plan row counts are inconsistent") + + semantics = _required_mapping(payload, "resolved_semantics") + semantic_fields = tuple( + _semantic_field_row(name, value) for name, value in sorted(semantics.items()) + ) + reference = _required_mapping(payload, "reference_state") + reference_required = _boolean( + reference.get("requires_context_compatibility"), + "reference-context requirement", + ) + reference_compatible = _boolean( + reference.get("compatible"), + "reference-context compatibility", + ) + reference_field_rows: list[dict[str, object]] = [ + {"name": value} + for value in sorted( + _string_list( + reference.get("selected_reference_dependent_fields"), + "reference-dependent fields", + ) + ) + ] + reference_fields = tuple(reference_field_rows) + compatible_context = reference.get("compatible_context") + if compatible_context is None: + context: Mapping[str, object] = {} + elif isinstance(compatible_context, Mapping): + context = compatible_context + else: + raise ValueError("Preparation plan compatible reference context must be a mapping") + reference_contexts = tuple( + sorted( + ( + _reference_context_row(value) + for value in _mapping_list(reference.get("contexts"), "reference contexts") + ), + key=lambda row: (str(row["artifact"]), str(row["runId"])), + ) + ) + + exclusion_counts = _required_mapping(payload, "exclusion_reason_counts") + exclusion_reasons = tuple( + { + "reason": _nonempty_text(reason, "exclusion reason"), + "count": _nonnegative_integer(count, "exclusion reason count"), + } + for reason, count in sorted(exclusion_counts.items()) + ) + category_rows: list[dict[str, object]] = [ + {"field": field, "value": category} + for field, values in sorted(_required_mapping(payload, "categories").items()) + for category in _string_list(values, f"categories for {field}") + ] + categories = tuple(category_rows) + + scenario_rows: list[dict[str, object]] = [] + partition_rows: list[dict[str, object]] = [] + transformation_rows: list[dict[str, object]] = [] + leakage_rows: list[dict[str, object]] = [] + for scenario_order, scenario_value in enumerate( + _mapping_list(payload.get("scenarios"), "Preparation scenarios") + ): + name = _nonempty_text(scenario_value.get("name"), "scenario name") + kind = _nonempty_text(scenario_value.get("kind"), "scenario kind") + partition_counts = _required_mapping(scenario_value, "partition_counts") + ordered_partitions = _partition_count_rows(name, partition_counts) + transformations = _mapping_list( + scenario_value.get("transformations"), "scenario transformations" + ) + leakage = _required_mapping(scenario_value, "state_leakage") + duplicate_groups = _nonnegative_integer( + leakage.get("duplicate_state_group_count"), + "duplicate state group count", + ) + cross_partition_groups = _nonnegative_integer( + leakage.get("cross_partition_group_count"), + "cross-partition group count", + ) + scenario_row_count = sum(cast(int, row["rowCount"]) for row in ordered_partitions) + if scenario_row_count != eligible_rows: + raise ValueError( + f"Preparation plan scenario {name!r} does not partition every eligible row" + ) + scenario_rows.append( + { + "name": name, + "kind": kind, + "order": scenario_order, + "rowCount": scenario_row_count, + "partitionCount": len(ordered_partitions), + "transformationCount": len(transformations), + "duplicateStateGroupCount": duplicate_groups, + "crossPartitionGroupCount": cross_partition_groups, + } + ) + partition_rows.extend(ordered_partitions) + transformation_rows.extend( + _transformation_row(name, order, value) + for order, value in enumerate(transformations) + ) + leakage_rows.append( + { + "scenario": name, + "identityColumn": _nonempty_text( + leakage.get("identity_column"), "leakage identity column" + ), + "duplicateStateGroupCount": duplicate_groups, + "crossPartitionGroupCount": cross_partition_groups, + } + ) + + outputs = _required_mapping(payload, "outputs") + output_format_rows: list[dict[str, object]] = [ + {"name": value} + for value in _string_list(outputs.get("formats"), "Preparation output formats") + ] + output_formats = tuple(output_format_rows) + array_scopes: list[dict[str, object]] = [] + conversion_rows: list[dict[str, object]] = [] + auxiliary_rows: list[dict[str, object]] = [] + for scope_order, feasibility in enumerate( + _mapping_list(outputs.get("array_feasibility"), "array feasibility") + ): + scope = _nonempty_text(feasibility.get("scope"), "array scope") + scope_kind, array_scenario, partition = _array_scope_parts(scope) + status = _nonempty_text(feasibility.get("status"), "array feasibility status") + feature_shape = _optional_shape(feasibility.get("feature_shape"), "feature shape") + target_shape = _optional_shape(feasibility.get("target_shape"), "target shape") + formats = _optional_string_list(feasibility.get("formats"), "array formats") + auxiliary_shapes = _optional_mapping( + feasibility.get("auxiliary_shapes"), "auxiliary shapes" + ) + conversions = _optional_mapping( + feasibility.get("float_conversion"), "float-conversion evidence" + ) + conversion_count = 0 + for role, raw_fields in sorted(conversions.items()): + fields = _mapping(raw_fields, f"{role} conversion evidence") + for field, raw_metrics in sorted(fields.items()): + metrics = _mapping(raw_metrics, f"conversion evidence for {field}") + conversion_rows.append( + { + "scope": scope, + "role": _nonempty_text(role, "array conversion role"), + "field": _nonempty_text(field, "array conversion field"), + "maxAbsoluteError": _number( + metrics.get("max_abs_error"), "maximum absolute error" + ), + "maxRelativeError": _number( + metrics.get("max_rel_error"), "maximum relative error" + ), + "meanAbsoluteError": _number( + metrics.get("mean_abs_error"), "mean absolute error" + ), + } + ) + conversion_count += 1 + for field, raw_shape in sorted(auxiliary_shapes.items()): + shape = _shape(raw_shape, f"auxiliary shape for {field}") + auxiliary_rows.append( + { + "scope": scope, + "field": _nonempty_text(field, "auxiliary array field"), + "rowCount": shape[0], + "columnCount": shape[1], + } + ) + array_scopes.append( + { + "scope": scope, + "scopeKind": scope_kind, + "scenario": array_scenario, + "partition": partition, + "order": scope_order, + "status": status, + "dtype": _optional_text(feasibility.get("dtype"), "array dtype"), + "formats": formats, + "shapeAvailable": feature_shape is not None and target_shape is not None, + "featureRows": 0 if feature_shape is None else feature_shape[0], + "featureColumns": 0 if feature_shape is None else feature_shape[1], + "targetRows": 0 if target_shape is None else target_shape[0], + "targetColumns": 0 if target_shape is None else target_shape[1], + "auxiliaryArrayCount": len(auxiliary_shapes), + "conversionFieldCount": conversion_count, + } + ) + + baseline_checks: list[dict[str, object]] = [] + baseline_estimators: list[dict[str, object]] = [] + raw_baselines = payload.get("baseline_feasibility") + baselines = ( + [] if raw_baselines is None else _mapping_list(raw_baselines, "baseline feasibility") + ) + for order, baseline in enumerate(baselines): + baseline_scenario = _nullable_text(baseline.get("scenario"), "baseline scenario") + feature_columns = _optional_string_list( + baseline.get("feature_columns"), "baseline feature columns" + ) + target_columns = _optional_string_list( + baseline.get("target_columns"), "baseline target columns" + ) + estimators = _optional_mapping_list(baseline.get("estimators"), "baseline estimators") + train_rows = _first_shape_dimension( + baseline.get("train_shapes"), "baseline train shapes" + ) + evaluation_rows = _evaluation_shape_rows( + baseline.get("evaluation_shapes"), "baseline evaluation shapes" + ) + fit_performed = _optional_boolean(baseline.get("fit_performed"), "baseline fit state") + if fit_performed: + raise ValueError("Preparation planning must not report a fitted baseline") + baseline_checks.append( + { + "scenario": baseline_scenario, + "order": order, + "status": _nonempty_text(baseline.get("status"), "baseline feasibility status"), + "library": _optional_text(baseline.get("library"), "baseline library"), + "libraryVersion": _optional_text( + baseline.get("library_version"), "baseline library version" + ), + "featureCount": len(feature_columns), + "targetCount": len(target_columns), + "trainRowCount": train_rows, + "evaluationPartitionCount": len(evaluation_rows), + "evaluationRowCount": sum(evaluation_rows), + "estimatorCount": len(estimators), + "fitPerformed": fit_performed, + } + ) + baseline_estimators.extend( + { + "scenario": baseline_scenario, + "model": _nonempty_text(estimator.get("model"), "baseline model"), + "target": _nonempty_text(estimator.get("target"), "baseline target"), + "estimatorType": _nonempty_text( + estimator.get("estimator_type"), "baseline estimator type" + ), + } + for estimator in estimators + ) + + raw_matrix_diagnostics = payload.get("matrix_diagnostics") + matrix_diagnostics = ( + [] + if raw_matrix_diagnostics is None + else _mapping_list(raw_matrix_diagnostics, "matrix diagnostics") + ) + matrix_checks = tuple( + _matrix_check_row(order, diagnostic) + for order, diagnostic in enumerate(matrix_diagnostics) + ) + dependencies = tuple( + _dependency_row(name, value) + for name, value in sorted(_required_mapping(payload, "dependency_readiness").items()) + ) + return cls( + source_row_count=source_rows, + eligible_row_count=eligible_rows, + excluded_row_count=excluded_rows, + reference_context_required=reference_required, + reference_context_compatible=reference_compatible, + reference_policy=_optional_text( + context.get("reference_state_policy"), "reference-state policy" + ), + reference_backend=_optional_text(context.get("backend"), "reference backend"), + reference_backend_model=_optional_text( + context.get("backend_model"), "reference backend model" + ), + semantic_fields=semantic_fields, + reference_fields=reference_fields, + reference_contexts=reference_contexts, + exclusion_reasons=exclusion_reasons, + categories=categories, + scenarios=tuple(scenario_rows), + partitions=tuple(partition_rows), + transformations=tuple(transformation_rows), + leakage_audits=tuple(leakage_rows), + output_formats=output_formats, + array_scopes=tuple(array_scopes), + array_conversion_errors=tuple(conversion_rows), + array_auxiliary_shapes=tuple(auxiliary_rows), + matrix_checks=matrix_checks, + baseline_checks=tuple(baseline_checks), + baseline_estimators=tuple(baseline_estimators), + dependencies=dependencies, + ) + + +def _semantic_field_row(name: object, value: object) -> dict[str, object]: + field = _mapping(value, f"resolved semantics for {name}") + dependencies = _optional_string_list(field.get("dependencies"), "derived-field dependencies") + return { + "name": _nonempty_text(name, "resolved semantic field"), + "column": _nonempty_text(field.get("column"), "resolved semantic column"), + "unit": _optional_text(field.get("unit"), "resolved semantic unit"), + "kind": _nonempty_text(field.get("kind"), "resolved semantic kind"), + "source": _nonempty_text(field.get("source"), "resolved semantic source"), + "formula": _optional_text(field.get("formula"), "derived-field formula"), + "dependencies": dependencies, + "referenceStateSafe": _optional_boolean( + field.get("reference_state_safe"), "derived-field reference-state safety" + ), + "arrayExportAllowed": _optional_boolean( + field.get("array_export_allowed"), "derived-field array-export state" + ), + } + + +def _reference_context_row(value: Mapping[str, object]) -> dict[str, object]: + targets = _optional_string_list(value.get("reference_state_targets"), "reference-state targets") + return { + "artifact": _nonempty_text(value.get("artifact"), "reference-context artifact"), + "runId": _nonempty_text(value.get("run_id"), "reference-context run ID"), + "backend": _optional_text(value.get("backend"), "reference-context backend"), + "backendModel": _optional_text( + value.get("backend_model"), "reference-context backend model" + ), + "referenceStatePolicy": _optional_text( + value.get("reference_state_policy"), "reference-state policy" + ), + "referenceStateBackendModel": _optional_text( + value.get("reference_state_backend_model"), + "reference-state backend model", + ), + "targetCount": len(targets), + } + + +def _partition_count_rows( + scenario: str, + counts: Mapping[str, object], +) -> tuple[dict[str, object], ...]: + preferred = {"all": 0, "train": 1, "validation": 2, "test": 3} + values = [ + ( + _nonempty_text(partition, "scenario partition"), + _nonnegative_integer(count, "scenario partition count"), + ) + for partition, count in counts.items() + ] + values.sort(key=lambda item: (preferred.get(item[0], len(preferred)), item[0])) + return tuple( + { + "scenario": scenario, + "partition": partition, + "order": order, + "rowCount": count, + } + for order, (partition, count) in enumerate(values) + ) + + +def _transformation_row( + scenario: str, + order: int, + value: Mapping[str, object], +) -> dict[str, object]: + methods = _string_list(value.get("methods"), "transformation methods") + steps = _mapping_list(value.get("steps"), "transformation steps") + if len(steps) != len(methods): + raise ValueError("Preparation plan transformation steps do not match its methods") + return { + "scenario": scenario, + "order": order, + "field": _nonempty_text(value.get("field"), "transformation field"), + "methods": methods, + "outputColumn": _nonempty_text(value.get("output_column"), "transformation output column"), + "fitPartition": _nonempty_text(value.get("fit_partition"), "transformation fit partition"), + "stepCount": len(steps), + } + + +def _array_scope_parts(scope: str) -> tuple[str, str, str]: + if scope == "table": + return "table", "", "" + parts = scope.split(":") + if len(parts) == 3 and parts[0] == "scenario" and parts[1] and parts[2]: + return "scenario_partition", parts[1], parts[2] + raise ValueError(f"Preparation plan array scope is malformed: {scope}") + + +def _matrix_check_row( + order: int, + value: Mapping[str, object], +) -> dict[str, object]: + feature_columns = _optional_string_list(value.get("feature_columns"), "matrix feature columns") + target_columns = _optional_string_list(value.get("target_columns"), "matrix target columns") + constant_features = _optional_string_list( + value.get("constant_feature_columns"), "constant feature columns" + ) + variable_features = _optional_string_list( + value.get("variable_feature_columns"), "variable feature columns" + ) + near_constant_features = _optional_mapping_list( + value.get("near_constant_feature_columns"), "near-constant feature columns" + ) + correlated_pairs = _optional_mapping_list( + value.get("highly_correlated_feature_pairs"), "correlated feature pairs" + ) + target_correlations = _optional_mapping_list( + value.get("feature_target_correlations"), "feature-target correlations" + ) + condition = value.get("condition_number") + return { + "scenario": _nullable_text(value.get("scenario"), "matrix scenario"), + "order": order, + "fitPartition": _nonempty_text(value.get("fit_partition"), "matrix fit partition"), + "status": _nonempty_text(value.get("status"), "matrix diagnostic status"), + "rowCount": _optional_nonnegative_integer(value.get("row_count"), "matrix row count"), + "featureCount": len(feature_columns), + "targetCount": len(target_columns), + "constantFeatureCount": len(constant_features), + "nearConstantFeatureCount": len(near_constant_features), + "variableFeatureCount": len(variable_features), + "numericalRank": _optional_nonnegative_integer( + value.get("numerical_rank"), "matrix numerical rank" + ), + "effectiveRank": _optional_number(value.get("effective_rank"), "effective rank"), + "conditionNumberAvailable": condition is not None, + "conditionNumber": _optional_number(condition, "condition number"), + "conditionNumberInfinite": _optional_boolean( + value.get("condition_number_is_infinite"), "infinite condition-number state" + ), + "correlatedPairCount": len(correlated_pairs), + "featureTargetCorrelationCount": len(target_correlations), + } + + +def _dependency_row(name: object, value: object) -> dict[str, object]: + dependency = _mapping(value, f"dependency readiness for {name}") + return { + "name": _nonempty_text(name, "dependency name"), + "available": _boolean(dependency.get("available"), "dependency availability"), + "version": _optional_text(dependency.get("version"), "dependency version"), + } + + +def _required_mapping( + mapping: Mapping[str, object], + key: str, +) -> Mapping[str, object]: + return _mapping(mapping.get(key), key) + + +def _mapping(value: object, label: str) -> Mapping[str, object]: + if not isinstance(value, Mapping) or not all(isinstance(key, str) for key in value): + raise ValueError(f"Preparation plan {label} must be a string-keyed mapping") + return cast(Mapping[str, object], value) + + +def _optional_mapping(value: object, label: str) -> Mapping[str, object]: + return {} if value is None else _mapping(value, label) + + +def _mapping_list(value: object, label: str) -> list[Mapping[str, object]]: + if not isinstance(value, list): + raise ValueError(f"Preparation plan {label} must be a list") + return [_mapping(item, f"{label} entry") for item in value] + + +def _optional_mapping_list(value: object, label: str) -> list[Mapping[str, object]]: + return [] if value is None else _mapping_list(value, label) + + +def _string_list(value: object, label: str) -> list[str]: + if ( + not isinstance(value, list) + or not all(isinstance(item, str) and item for item in value) + or len(set(value)) != len(value) + ): + raise ValueError(f"Preparation plan {label} must be unique non-empty text") + return cast(list[str], value) + + +def _optional_string_list(value: object, label: str) -> list[str]: + return [] if value is None else _string_list(value, label) + + +def _nonempty_text(value: object, label: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError(f"Preparation plan {label} must be non-empty text") + return value + + +def _optional_text(value: object, label: str) -> str: + if value is None: + return "" + if not isinstance(value, str): + raise ValueError(f"Preparation plan {label} must be text or null") + return value + + +def _nullable_text(value: object, label: str) -> str: + if value is None: + return "" + return _nonempty_text(value, label) + + +def _boolean(value: object, label: str) -> bool: + if not isinstance(value, bool): + raise ValueError(f"Preparation plan {label} must be boolean") + return value + + +def _optional_boolean(value: object, label: str) -> bool: + return False if value is None else _boolean(value, label) + + +def _nonnegative_integer(value: object, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"Preparation plan {label} must be a non-negative integer") + return value + + +def _optional_nonnegative_integer(value: object, label: str) -> int: + return 0 if value is None else _nonnegative_integer(value, label) + + +def _number(value: object, label: str) -> float: + if not isinstance(value, int | float) or isinstance(value, bool): + raise ValueError(f"Preparation plan {label} must be numeric") + return float(value) + + +def _optional_number(value: object, label: str) -> float: + return 0.0 if value is None else _number(value, label) + + +def _shape(value: object, label: str) -> tuple[int, int]: + if not isinstance(value, list) or len(value) not in {1, 2}: + raise ValueError(f"Preparation plan {label} must be a one- or two-dimensional shape") + dimensions = tuple(_nonnegative_integer(dimension, f"{label} dimension") for dimension in value) + return (dimensions[0], 1 if len(dimensions) == 1 else dimensions[1]) + + +def _optional_shape(value: object, label: str) -> tuple[int, int] | None: + return None if value is None else _shape(value, label) + + +def _first_shape_dimension(value: object, label: str) -> int: + if value is None: + return 0 + shapes = _mapping(value, label) + features = shapes.get("features") + return 0 if features is None else _shape(features, f"{label} features")[0] + + +def _evaluation_shape_rows(value: object, label: str) -> list[int]: + if value is None: + return [] + evaluations = _mapping(value, label) + rows: list[int] = [] + for partition, raw_shapes in sorted(evaluations.items()): + shapes = _mapping(raw_shapes, f"{label} for {partition}") + features = shapes.get("features") + if features is None: + raise ValueError(f"Preparation plan {label} is missing feature shape") + rows.append(_shape(features, f"{label} features for {partition}")[0]) + return rows + + +class PreparationPlanModel(QObject): + """Own the fixed QML models for the last accepted Preparation plan.""" + + changed = Signal() + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._projection: PreparationPlanProjection | None = None + self.semantic_fields = WorkflowListModel( + ( + "name", + "column", + "unit", + "kind", + "source", + "formula", + "dependencies", + "referenceStateSafe", + "arrayExportAllowed", + ), + self, + ) + self.reference_fields = WorkflowListModel(("name",), self) + self.reference_contexts = WorkflowListModel( + ( + "artifact", + "runId", + "backend", + "backendModel", + "referenceStatePolicy", + "referenceStateBackendModel", + "targetCount", + ), + self, + ) + self.exclusion_reasons = WorkflowListModel(("reason", "count"), self) + self.category_values = WorkflowListModel(("field", "value"), self) + self.scenarios = WorkflowListModel( + ( + "name", + "kind", + "order", + "rowCount", + "partitionCount", + "transformationCount", + "duplicateStateGroupCount", + "crossPartitionGroupCount", + ), + self, + ) + self.partitions = WorkflowListModel(("scenario", "partition", "order", "rowCount"), self) + self.transformations = WorkflowListModel( + ( + "scenario", + "order", + "field", + "methods", + "outputColumn", + "fitPartition", + "stepCount", + ), + self, + ) + self.leakage_audits = WorkflowListModel( + ( + "scenario", + "identityColumn", + "duplicateStateGroupCount", + "crossPartitionGroupCount", + ), + self, + ) + self.output_formats = WorkflowListModel(("name",), self) + self.array_feasibility = WorkflowListModel( + ( + "scope", + "scopeKind", + "scenario", + "partition", + "order", + "status", + "dtype", + "formats", + "shapeAvailable", + "featureRows", + "featureColumns", + "targetRows", + "targetColumns", + "auxiliaryArrayCount", + "conversionFieldCount", + ), + self, + ) + self.array_conversion_errors = WorkflowListModel( + ( + "scope", + "role", + "field", + "maxAbsoluteError", + "maxRelativeError", + "meanAbsoluteError", + ), + self, + ) + self.array_auxiliary_shapes = WorkflowListModel( + ("scope", "field", "rowCount", "columnCount"), self + ) + self.matrix_diagnostics = WorkflowListModel( + ( + "scenario", + "order", + "fitPartition", + "status", + "rowCount", + "featureCount", + "targetCount", + "constantFeatureCount", + "nearConstantFeatureCount", + "variableFeatureCount", + "numericalRank", + "effectiveRank", + "conditionNumberAvailable", + "conditionNumber", + "conditionNumberInfinite", + "correlatedPairCount", + "featureTargetCorrelationCount", + ), + self, + ) + self.baseline_feasibility = WorkflowListModel( + ( + "scenario", + "order", + "status", + "library", + "libraryVersion", + "featureCount", + "targetCount", + "trainRowCount", + "evaluationPartitionCount", + "evaluationRowCount", + "estimatorCount", + "fitPerformed", + ), + self, + ) + self.baseline_estimators = WorkflowListModel( + ("scenario", "model", "target", "estimatorType"), self + ) + self.dependencies = WorkflowListModel(("name", "available", "version"), self) + + def replace(self, projection: PreparationPlanProjection) -> None: + self._projection = projection + rows = ( + projection.semantic_fields, + projection.reference_fields, + projection.reference_contexts, + projection.exclusion_reasons, + projection.categories, + projection.scenarios, + projection.partitions, + projection.transformations, + projection.leakage_audits, + projection.output_formats, + projection.array_scopes, + projection.array_conversion_errors, + projection.array_auxiliary_shapes, + projection.matrix_checks, + projection.baseline_checks, + projection.baseline_estimators, + projection.dependencies, + ) + for model, values in zip(self._models(), rows, strict=True): + model.replace(values) + self.changed.emit() + + def clear(self) -> None: + if self._projection is None: + return + self._projection = None + for model in self._models(): + model.clear() + self.changed.emit() + + def _models(self) -> tuple[WorkflowListModel, ...]: + return ( + self.semantic_fields, + self.reference_fields, + self.reference_contexts, + self.exclusion_reasons, + self.category_values, + self.scenarios, + self.partitions, + self.transformations, + self.leakage_audits, + self.output_formats, + self.array_feasibility, + self.array_conversion_errors, + self.array_auxiliary_shapes, + self.matrix_diagnostics, + self.baseline_feasibility, + self.baseline_estimators, + self.dependencies, + ) + + def get_available(self) -> bool: + return self._projection is not None + + available = Property(bool, get_available, notify=changed) + + def get_source_row_count(self) -> int: + return 0 if self._projection is None else self._projection.source_row_count + + sourceRowCount = Property(int, get_source_row_count, notify=changed) + + def get_eligible_row_count(self) -> int: + return 0 if self._projection is None else self._projection.eligible_row_count + + eligibleRowCount = Property(int, get_eligible_row_count, notify=changed) + + def get_excluded_row_count(self) -> int: + return 0 if self._projection is None else self._projection.excluded_row_count + + excludedRowCount = Property(int, get_excluded_row_count, notify=changed) + + def get_reference_context_required(self) -> bool: + return bool(self._projection is not None and self._projection.reference_context_required) + + referenceContextRequired = Property(bool, get_reference_context_required, notify=changed) + + def get_reference_context_compatible(self) -> bool: + return bool(self._projection is not None and self._projection.reference_context_compatible) + + referenceContextCompatible = Property( + bool, + get_reference_context_compatible, + notify=changed, + ) + + def get_reference_policy(self) -> str: + return "" if self._projection is None else self._projection.reference_policy + + referencePolicy = Property(str, get_reference_policy, notify=changed) + + def get_reference_backend(self) -> str: + return "" if self._projection is None else self._projection.reference_backend + + referenceBackend = Property(str, get_reference_backend, notify=changed) + + def get_reference_backend_model(self) -> str: + return "" if self._projection is None else self._projection.reference_backend_model + + referenceBackendModel = Property(str, get_reference_backend_model, notify=changed) + + semanticFields = Property(QObject, lambda self: self.semantic_fields, constant=True) + referenceFields = Property(QObject, lambda self: self.reference_fields, constant=True) + referenceContexts = Property(QObject, lambda self: self.reference_contexts, constant=True) + exclusionReasons = Property(QObject, lambda self: self.exclusion_reasons, constant=True) + categoryValues = Property(QObject, lambda self: self.category_values, constant=True) + scenariosModel = Property(QObject, lambda self: self.scenarios, constant=True) + partitionsModel = Property(QObject, lambda self: self.partitions, constant=True) + transformationsModel = Property(QObject, lambda self: self.transformations, constant=True) + leakageAudits = Property(QObject, lambda self: self.leakage_audits, constant=True) + outputFormats = Property(QObject, lambda self: self.output_formats, constant=True) + arrayFeasibility = Property(QObject, lambda self: self.array_feasibility, constant=True) + arrayConversionErrors = Property( + QObject, lambda self: self.array_conversion_errors, constant=True + ) + arrayAuxiliaryShapes = Property( + QObject, lambda self: self.array_auxiliary_shapes, constant=True + ) + matrixDiagnostics = Property(QObject, lambda self: self.matrix_diagnostics, constant=True) + baselineFeasibility = Property(QObject, lambda self: self.baseline_feasibility, constant=True) + baselineEstimators = Property(QObject, lambda self: self.baseline_estimators, constant=True) + dependencyReadiness = Property(QObject, lambda self: self.dependencies, constant=True) + + +@dataclass(frozen=True) +class WorkflowIssue: + """One private, stable workflow issue projected to QML.""" + + origin: IssueOrigin + severity: IssueSeverity + code: str + message: str + document_kind: str + section: str + field_id: str = "" + item_key: str = "" + nested_row: int = -1 + path: tuple[str | int, ...] = () + + +class WorkflowIssueModel(QAbstractListModel): + """Expose structured workflow issues through fixed QML model roles.""" + + count_changed = Signal() + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._issues: tuple[WorkflowIssue, ...] = () + + @property + def issues(self) -> tuple[WorkflowIssue, ...]: + return self._issues + + def replace(self, issues: tuple[WorkflowIssue, ...]) -> bool: + if issues == self._issues: + return False + previous_count = len(self._issues) + self.beginResetModel() + self._issues = issues + self.endResetModel() + if len(self._issues) != previous_count: + self.count_changed.emit() + return True + + def rowCount( + self, + _parent: QModelIndex | QPersistentModelIndex = INVALID_INDEX, + ) -> int: + return len(self._issues) + + def data( + self, + index: QModelIndex | QPersistentModelIndex, + role: int = int(Qt.ItemDataRole.DisplayRole), + ) -> object: + if not index.isValid() or not 0 <= index.row() < len(self._issues): + return None + issue = self._issues[index.row()] + values: dict[int, object] = { + int(Qt.ItemDataRole.DisplayRole): issue.message, + int(Qt.ItemDataRole.ToolTipRole): issue.message, + ORIGIN_ROLE: issue.origin, + SEVERITY_ROLE: issue.severity, + CODE_ROLE: issue.code, + MESSAGE_ROLE: issue.message, + DOCUMENT_KIND_ROLE: issue.document_kind, + SECTION_ROLE: issue.section, + FIELD_ID_ROLE: issue.field_id, + ITEM_KEY_ROLE: issue.item_key, + NESTED_ROW_ROLE: issue.nested_row, + PATH_ROLE: list(issue.path), + } + return values.get(role) + + def roleNames(self) -> dict[int, QByteArray]: + return { + ORIGIN_ROLE: QByteArray(b"origin"), + SEVERITY_ROLE: QByteArray(b"severity"), + CODE_ROLE: QByteArray(b"code"), + MESSAGE_ROLE: QByteArray(b"message"), + DOCUMENT_KIND_ROLE: QByteArray(b"documentKind"), + SECTION_ROLE: QByteArray(b"section"), + FIELD_ID_ROLE: QByteArray(b"fieldId"), + ITEM_KEY_ROLE: QByteArray(b"itemKey"), + NESTED_ROW_ROLE: QByteArray(b"nestedRow"), + PATH_ROLE: QByteArray(b"path"), + } + + def get_count(self) -> int: + return len(self._issues) + + count = Property(int, get_count, notify=count_changed) diff --git a/src/carnopy/preparation/fields.py b/src/carnopy/preparation/fields.py index b60c594..8038fdd 100644 --- a/src/carnopy/preparation/fields.py +++ b/src/carnopy/preparation/fields.py @@ -69,6 +69,15 @@ class ResolvedPreparation: semantic_mapping: dict[str, dict[str, Any]] +@dataclass(frozen=True) +class PreparationFieldCapabilities: + """Source fields that the existing preparation resolver can consume.""" + + numeric: tuple[ResolvedField, ...] + categorical: tuple[ResolvedField, ...] + auxiliary: tuple[ResolvedField, ...] + + def resolve_preparation_fields( config: PreparationConfig, tables: tuple[SourceTable, ...], @@ -117,6 +126,44 @@ def resolve_preparation_fields( ) +def preparation_field_capabilities( + tables: tuple[SourceTable, ...], +) -> PreparationFieldCapabilities: + """Project available fields without weakening the configuration resolver.""" + if not tables: + raise ConfigError("preparation source contains no source tables") + + numeric = tuple( + resolved + for name in (*COORDINATE_FIELDS, *PROPERTY_REGISTRY) + if (resolved := _optional_numeric(name, tables)) is not None + ) + categorical = tuple( + resolved + for name in ("phase", "fluid") + if (resolved := _optional_categorical(name, tables)) is not None + ) + auxiliary_names = tuple( + dict.fromkeys( + ( + *(field.semantic_name for field in numeric), + *CATEGORICAL_FIELDS, + *sorted(AUXILIARY_SOURCE_FIELDS), + ) + ) + ) + auxiliary = tuple( + resolved + for name in auxiliary_names + if (resolved := _optional_auxiliary(name, tables)) is not None + ) + return PreparationFieldCapabilities( + numeric=numeric, + categorical=categorical, + auxiliary=auxiliary, + ) + + def derived_formula(feature: DerivedFeature) -> str: return derived_definition(feature).formula @@ -271,6 +318,36 @@ def _resolve_auxiliary(field: str, tables: tuple[SourceTable, ...]) -> ResolvedF raise ConfigError(f"unknown or unavailable auxiliary preparation field: {field}") +def _optional_numeric( + field: str, + tables: tuple[SourceTable, ...], +) -> ResolvedField | None: + try: + return _resolve_numeric(field, tables) + except ConfigError: + return None + + +def _optional_categorical( + field: str, + tables: tuple[SourceTable, ...], +) -> ResolvedField | None: + try: + return _resolve_categorical(field, tables) + except ConfigError: + return None + + +def _optional_auxiliary( + field: str, + tables: tuple[SourceTable, ...], +) -> ResolvedField | None: + try: + return _resolve_auxiliary(field, tables) + except ConfigError: + return None + + def _from_column( field: str, column: str, diff --git a/src/carnopy/preparation/models.py b/src/carnopy/preparation/models.py index 9b78ab1..bc55403 100644 --- a/src/carnopy/preparation/models.py +++ b/src/carnopy/preparation/models.py @@ -521,6 +521,14 @@ def load_preparation_config_bytes( raise ConfigError(f"invalid YAML in {config_path}: {exc}") from exc if not isinstance(payload, dict): raise ConfigError("preparation configuration root must be a YAML mapping") + return _load_preparation_config_payload(config_path, raw_bytes, payload) + + +def _load_preparation_config_payload( + config_path: Path, + raw_bytes: bytes, + payload: dict[str, Any], +) -> LoadedPreparationConfig: try: model = PreparationConfig.model_validate(payload) except ValidationError as exc: diff --git a/src/carnopy/preparation/reference.py b/src/carnopy/preparation/reference.py index 59ed010..4ffc925 100644 --- a/src/carnopy/preparation/reference.py +++ b/src/carnopy/preparation/reference.py @@ -13,16 +13,24 @@ def build_reference_state_summary( resolved: ResolvedPreparation, ) -> dict[str, Any]: selected = _selected_reference_dependent_fields(resolved) - contexts = [_context_for_table(table) for table in source_data.tables] + capability = assess_reference_context(source_data) summary: dict[str, Any] = { "selected_reference_dependent_fields": selected, "requires_context_compatibility": bool(selected), - "contexts": contexts, + "contexts": capability["contexts"], "compatible": True, } if not selected: return summary + if not capability["compatible"]: + raise ConfigError(str(capability["reason"])) + summary["compatible_context"] = capability["compatible_context"] + return summary + +def assess_reference_context(source_data: LoadedPreparationSource) -> dict[str, Any]: + """Describe whether reference-dependent fields share one source context.""" + contexts = [_context_for_table(table) for table in source_data.tables] missing = [ context["artifact"] for context in contexts @@ -31,10 +39,16 @@ def build_reference_state_summary( or context["backend_model"] is None ] if missing: - raise ConfigError( - "reference-dependent preparation fields require source reference-state " - "metadata; missing context for: " + ", ".join(missing) - ) + return { + "compatible": False, + "compatible_context": None, + "contexts": contexts, + "reason_code": "missing_reference_context", + "reason": ( + "reference-dependent preparation fields require source reference-state " + "metadata; missing context for: " + ", ".join(missing) + ), + } compatibility_keys = { ( context["reference_state_policy"], @@ -44,19 +58,32 @@ def build_reference_state_summary( for context in contexts } if len(compatibility_keys) != 1: - raise ConfigError( - "reference-dependent preparation fields require one compatible " - "reference-state context across selected source rows " - "(reference_state_policy, backend, backend_model); found: " - + ", ".join(" / ".join(str(part) for part in key) for key in sorted(compatibility_keys)) + found = ", ".join( + " / ".join(str(part) for part in key) for key in sorted(compatibility_keys) ) + return { + "compatible": False, + "compatible_context": None, + "contexts": contexts, + "reason_code": "incompatible_reference_context", + "reason": ( + "reference-dependent preparation fields require one compatible " + "reference-state context across selected source rows " + "(reference_state_policy, backend, backend_model); found: " + found + ), + } policy, backend, backend_model = next(iter(compatibility_keys)) - summary["compatible_context"] = { - "reference_state_policy": policy, - "backend": backend, - "backend_model": backend_model, + return { + "compatible": True, + "compatible_context": { + "reference_state_policy": policy, + "backend": backend, + "backend_model": backend_model, + }, + "contexts": contexts, + "reason_code": "", + "reason": "", } - return summary def _selected_reference_dependent_fields(resolved: ResolvedPreparation) -> list[str]: diff --git a/src/carnopy/sweeps/pipeline.py b/src/carnopy/sweeps/pipeline.py index 5ced63b..ee9a555 100644 --- a/src/carnopy/sweeps/pipeline.py +++ b/src/carnopy/sweeps/pipeline.py @@ -136,6 +136,7 @@ def _run_model_sweep( values_path=comparison.values_path, deltas_path=comparison.deltas_path, output_directory=layout.staging_directory / "comparison_plots", + finalized_output_directory=layout.final_directory / "comparison_plots", sweep_identity=_sweep_identity( sweep_id=sweep_id, sweep_run_id=sweep_run_id, diff --git a/src/carnopy/sweeps/plots.py b/src/carnopy/sweeps/plots.py index 83e4a5d..c8345f0 100644 --- a/src/carnopy/sweeps/plots.py +++ b/src/carnopy/sweeps/plots.py @@ -25,6 +25,7 @@ def render_comparison_plots( values_path: Path, deltas_path: Path, output_directory: Path, + finalized_output_directory: Path, sweep_identity: dict[str, str], selected_models: tuple[str, ...], reference_model: str, @@ -48,6 +49,8 @@ def render_comparison_plots( checkpoint() image_path = output_directory / f"{plot.name}.{plot.format or comparison_plots.format}" sidecar_path = output_directory / f"{plot.name}.plot.json" + finalized_image_path = finalized_output_directory / image_path.name + finalized_sidecar_path = finalized_output_directory / sidecar_path.name try: comparison_hashes = { "comparison/values.parquet": values_hash, @@ -62,6 +65,7 @@ def render_comparison_plots( fluid_aliases=fluid_aliases, image_path=image_path, sidecar_path=sidecar_path, + finalized_image_path=finalized_image_path, sweep_identity=sweep_identity, comparison_hashes=comparison_hashes, ) @@ -73,6 +77,7 @@ def render_comparison_plots( fluid_aliases=fluid_aliases, image_path=image_path, sidecar_path=sidecar_path, + finalized_image_path=finalized_image_path, sweep_identity=sweep_identity, comparison_hashes=comparison_hashes, ) @@ -94,8 +99,8 @@ def render_comparison_plots( "name": plot.name, "kind": plot.kind, "status": "completed", - "image_path": str(image_path), - "sidecar_path": str(sidecar_path), + "image_path": str(finalized_image_path), + "sidecar_path": str(finalized_sidecar_path), } ) finally: @@ -129,6 +134,7 @@ def _render_property_comparison( fluid_aliases: dict[str, str], image_path: Path, sidecar_path: Path, + finalized_image_path: Path, sweep_identity: dict[str, str], comparison_hashes: dict[str, str], ) -> None: @@ -220,7 +226,7 @@ def _render_property_comparison( "filters": plot.filters, "skipped_rows": int(sum(skipped_reasons.values())), "missing_or_invalid_reasons": skipped_reasons, - "image": {"path": str(image_path), "sha256": image_hash}, + "image": {"path": str(finalized_image_path), "sha256": image_hash}, "runtime_versions": { "carnopy": __version__, "matplotlib": metadata.version("matplotlib"), @@ -238,6 +244,7 @@ def _render_property_delta( fluid_aliases: dict[str, str], image_path: Path, sidecar_path: Path, + finalized_image_path: Path, sweep_identity: dict[str, str], comparison_hashes: dict[str, str], ) -> None: @@ -345,7 +352,7 @@ def _render_property_delta( "metric_summary": metric_summary, "skipped_rows": int(sum(skipped_reasons.values())), "missing_or_invalid_reasons": skipped_reasons, - "image": {"path": str(image_path), "sha256": image_hash}, + "image": {"path": str(finalized_image_path), "sha256": image_hash}, "runtime_versions": { "carnopy": __version__, "matplotlib": metadata.version("matplotlib"), diff --git a/tests/test_app_comparison_plot_draft.py b/tests/test_app_comparison_plot_draft.py new file mode 100644 index 0000000..5633dd2 --- /dev/null +++ b/tests/test_app_comparison_plot_draft.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from carnopy.app.comparison_plot_draft import ComparisonPlotDraft +from carnopy.config.sweep import ComparisonPlotConfig + + +def _draft(payload: dict[str, object] | None = None) -> ComparisonPlotDraft: + return ComparisonPlotDraft( + selected_models=("heos", "pr", "srk"), + reference_model="heos", + fluids=("R290",), + properties=("mass_density", "specific_enthalpy"), + fluid_aliases={"R290": "Propane", "Propane": "Propane"}, + categorical_values={"saturation_endpoint": ("saturated_liquid", "saturated_vapor")}, + payload=payload, + ) + + +def _normalized(payload: dict[str, object]) -> dict[str, object]: + return ComparisonPlotConfig.model_validate(payload).model_dump( + mode="json", + by_alias=True, + exclude_none=True, + ) + + +def test_property_comparison_round_trips_every_public_field() -> None: + payload: dict[str, object] = { + "name": "density-comparison", + "kind": "property_comparison", + "fluid": "Propane", + "property": "mass_density", + "x": "temperature", + "group_by": "pressure", + "filters": { + "pressure": 100_000.0, + "saturation_endpoint": "saturated_liquid", + }, + "models": ["heos", "pr"], + "delta_metric": "signed_absolute_difference", + "value_scale": "log", + "format": "svg", + } + + draft = _draft(payload) + + assert draft.get_locally_valid() + assert draft.payload() == _normalized(payload) + assert draft.get_explicit_models() + assert draft.filters.raw_rows() == ( + ("pressure", "100000"), + ("saturation_endpoint", "saturated_liquid"), + ) + reference = next(item for item in draft.model_choices.items if item.value == "heos") + assert reference.selected + assert reference.compatible + + +def test_property_delta_round_trips_metric_models_scale_and_format() -> None: + payload: dict[str, object] = { + "name": "density-delta", + "kind": "property_delta", + "fluid": "R290", + "property": "mass_density", + "x": "pressure", + "models": ["pr", "srk"], + "delta_metric": "signed_absolute_difference", + "value_scale": "linear", + "format": "pdf", + } + + draft = _draft(payload) + + assert draft.get_locally_valid() + assert draft.payload() == _normalized(payload) + reference = next(item for item in draft.model_choices.items if item.value == "heos") + assert not reference.selected + assert not reference.compatible + assert "implicitly" in reference.issue + + +def test_kind_change_retains_explicit_reference_selection_as_blocking() -> None: + draft = _draft() + draft.set_name("kind-change") + draft.set_fluid("Propane") + draft.set_property_name("mass_density") + draft.set_explicit_models(True) + + assert draft.get_locally_valid() + assert draft.set_model_selected("heos", True) is False + draft.set_kind("property_delta") + + assert not draft.get_locally_valid() + assert "reference model" in draft.get_issue() + assert draft.get_first_invalid_field() == "sweep.comparison.active.models" + assert draft.set_model_selected("heos", False) + assert draft.get_locally_valid() + assert not draft.set_model_selected("heos", True) + + +def test_implicit_and_empty_explicit_models_preserve_public_schema_distinction() -> None: + implicit = _draft( + { + "name": "implicit", + "kind": "property_delta", + "fluid": "R290", + "property": "mass_density", + "x": "temperature", + } + ) + explicit_empty = _draft( + { + "name": "explicit-empty", + "kind": "property_delta", + "fluid": "R290", + "property": "mass_density", + "x": "temperature", + "models": [], + } + ) + + assert implicit.get_locally_valid() + assert "models" not in implicit.payload() + assert explicit_empty.get_locally_valid() + assert explicit_empty.payload()["models"] == [] + + +def test_invalid_filter_rows_remain_visible_with_stable_focus() -> None: + draft = _draft( + { + "name": "invalid-filter", + "kind": "property_comparison", + "fluid": "R290", + "property": "mass_density", + "x": "temperature", + "filters": {"backend_model": "pr"}, + } + ) + + assert draft.filters.raw_rows() == (("backend_model", "pr"),) + assert not draft.get_locally_valid() + assert draft.get_first_invalid_field() == "sweep.comparison.active.filters" + assert draft.get_first_invalid_row() == 0 + + +def test_detached_payload_does_not_share_nested_filter_state() -> None: + draft = _draft( + { + "name": "detached", + "kind": "property_comparison", + "fluid": "R290", + "property": "mass_density", + "x": "temperature", + "filters": {"pressure": 100_000.0}, + } + ) + + detached = draft.detached_payload() + detached["filters"]["pressure"] = 200_000.0 + + assert draft.payload()["filters"] == {"pressure": 100_000.0} + + +def test_comparison_plot_draft_import_is_qtcore_only_and_scientifically_isolated() -> None: + code = """ +import sys +import carnopy.app.comparison_plot_draft +for name in ( + "PySide6.QtWidgets", "CoolProp", "numpy", "pandas", "pyarrow", "matplotlib", + "carnopy.cli", "carnopy.pipeline", +): + if name in sys.modules: + raise SystemExit(name) +""" + completed = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr diff --git a/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index 59eb750..7c141b0 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -8,22 +8,26 @@ from uuid import UUID, uuid4 import pytest +import yaml os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") pytest.importorskip("PySide6") from PySide6.QtCore import QCoreApplication, QEvent, QObject, Signal -from carnopy.app.config_controller import DatasetConfigController +import carnopy.app.config_controller as config_controllers +from carnopy.app.config_controller import ConfigurationController from carnopy.app.config_document import ( ConfigDocumentError, - DatasetConfigDocument, + ConfigurationDocument, new_document, + serialize_configuration, serialize_dataset_config, sha256_bytes, ) from carnopy.app.request_coordinator import DesktopRequestCoordinator, RequestSession from carnopy.app.workspace import initialize_workspace +from carnopy.templates import template_text class StubSession(QObject): @@ -205,11 +209,23 @@ def payload(*, visualization: bool = False) -> dict[str, Any]: return value +def sweep_payload() -> dict[str, Any]: + value = yaml.safe_load(template_text("model_sweep")) + assert isinstance(value, dict) + return cast(dict[str, Any], value) + + +def preparation_payload() -> dict[str, Any]: + value = yaml.safe_load(template_text("preparation")) + assert isinstance(value, dict) + return cast(dict[str, Any], value) + + def configured_controller( tmp_path: Path, -) -> tuple[DatasetConfigController, StubCoordinator]: +) -> tuple[ConfigurationController, StubCoordinator]: coordinator = StubCoordinator() - controller = DatasetConfigController(cast(DesktopRequestCoordinator, coordinator)) + controller = ConfigurationController(cast(DesktopRequestCoordinator, coordinator)) workspace = initialize_workspace(tmp_path / "workspace") controller.set_workspace(workspace) @@ -219,6 +235,10 @@ def configured_controller( return controller, coordinator +def test_dataset_specific_controller_alias_is_removed() -> None: + assert not hasattr(config_controllers, "DatasetConfigController") + + def test_controller_owns_complete_merge_dirty_and_execution_gates( tmp_path: Path, application: QCoreApplication, @@ -334,6 +354,42 @@ def test_standalone_validation_is_bound_to_one_exact_document_revision( assert controller.get_can_validate() +def test_validation_response_cannot_cross_an_identical_document_replacement( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, coordinator = configured_controller(tmp_path) + controller.open_document(new_document(payload())) + + assert controller.request_validation() + replacement = new_document(payload()) + assert controller.open_document(replacement) + coordinator.succeed({}) + + assert controller.document is replacement + assert controller.get_worker_validation_state() == "not_run" + + +def test_save_response_cannot_write_after_identical_document_replacement( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, coordinator = configured_controller(tmp_path) + controller.open_document(new_document(payload())) + destination = controller.workspace.configs / "obsolete.yaml" + + assert controller.request_save_as() + assert controller.save_path_selected(str(destination)) + replacement = new_document(payload()) + assert controller.open_document(replacement) + coordinator.succeed({}) + + assert controller.document is replacement + assert not destination.exists() + + def test_standalone_validation_classifies_structured_failures_without_issues( tmp_path: Path, application: QCoreApplication, @@ -533,6 +589,70 @@ def test_successful_import_reports_typed_source_location( assert controller.get_yaml_available() +def test_import_response_cannot_replace_a_newer_document_generation( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, coordinator = configured_controller(tmp_path) + source = tmp_path / "external.yaml" + content = serialize_dataset_config(payload()) + source.write_bytes(content) + + assert controller.import_configuration(str(source)) + replacement = new_document(sweep_payload()) + assert controller.open_document(replacement) + coordinator.succeed( + { + "document_type": "dataset", + "config": payload(), + "source_name": str(source), + "source_sha256": sha256_bytes(content), + } + ) + + assert controller.document is replacement + assert controller.get_document_kind() == "model_sweep" + + +def test_import_rejects_a_worker_result_for_another_source( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, coordinator = configured_controller(tmp_path) + requested = tmp_path / "requested.yaml" + returned = tmp_path / "returned.yaml" + content = serialize_dataset_config(payload()) + requested.write_bytes(content) + failures: list[tuple[str, str, str, list[dict[str, str]]]] = [] + controller.operationFailed.connect( + lambda operation, title, message, issues: failures.append( + (operation, title, message, issues) + ) + ) + + assert controller.import_configuration(str(requested)) + coordinator.succeed( + { + "document_type": "dataset", + "config": payload(), + "source_name": str(returned), + "source_sha256": sha256_bytes(content), + } + ) + + assert controller.document is None + assert failures == [ + ( + "import", + "Import Failed", + "worker configuration result does not match its requested source", + [], + ) + ] + + def test_controller_refuses_stale_validated_bytes_if_draft_changes_in_flight( tmp_path: Path, application: QCoreApplication, @@ -573,7 +693,7 @@ def test_controller_owns_reformat_external_change_and_replacement_guards( source = workspace.configs / "imported.yaml" content = serialize_dataset_config(payload(visualization=True)) source.write_bytes(content) - document = DatasetConfigDocument( + document = ConfigurationDocument( payload(visualization=True), source_path=source, source_sha256=sha256_bytes(content), @@ -623,3 +743,404 @@ def test_config_controller_import_is_qtcore_only_and_scientifically_isolated() - ) assert completed.returncode == 0, completed.stdout + completed.stderr + + +@pytest.mark.parametrize( + ("document_type", "default_filename"), + [ + ("model_sweep", "model-sweep.yaml"), + ("preparation", "preparation.yaml"), + ], +) +def test_generic_controller_owns_non_dataset_file_lifecycle( + tmp_path: Path, + application: QCoreApplication, + document_type: str, + default_filename: str, +) -> None: + del application + controller, coordinator = configured_controller(tmp_path) + source = tmp_path / f"external-{document_type}.yaml" + source_bytes = template_text(cast(Any, document_type)).encode("utf-8") + source.write_bytes(source_bytes) + source_payload = yaml.safe_load(source_bytes) + assert isinstance(source_payload, dict) + + assert controller.import_configuration(str(source)) + assert coordinator.calls[-1] == ( + "configuration", + "load_configuration", + {"config_path": str(source.resolve())}, + ) + coordinator.succeed( + { + "document_type": document_type, + "config": source_payload, + "source_name": str(source), + "source_sha256": sha256_bytes(source_bytes), + } + ) + + assert controller.get_document_kind() == document_type + assert controller.get_reformat_required() + assert controller.get_yaml_available() + assert controller.get_default_save_path().endswith(default_filename) + assert controller.document is not None + expected_bytes = serialize_configuration(source_payload) + assert controller.document.yaml_bytes == expected_bytes + + assert controller.request_validation() + assert coordinator.calls[-1] == ( + "configuration", + "validate_configuration", + { + "yaml_text": expected_bytes.decode("utf-8"), + "source_name": str(source.resolve()), + "expected_document_type": document_type, + }, + ) + coordinator.succeed({"document_type": document_type}) + + destination = controller.workspace.configs / f"saved-{document_type}.yaml" + assert controller.request_save_as(allow_reformat=True) + assert controller.save_path_selected(str(destination)) + assert coordinator.calls[-1] == ( + "configuration", + "validate_configuration", + { + "yaml_text": expected_bytes.decode("utf-8"), + "source_name": str(destination), + "expected_document_type": document_type, + }, + ) + coordinator.succeed({"document_type": document_type}) + + assert destination.read_bytes() == expected_bytes + assert not controller.get_dirty() + assert not controller.get_reformat_required() + snapshot = controller.execution_snapshot(expected_document_type=cast(Any, document_type)) + assert snapshot.document_type == document_type + assert snapshot.path == destination.resolve() + with pytest.raises(ConfigDocumentError, match="open configuration is"): + controller.execution_snapshot() + + assert controller.reload_source() + assert coordinator.calls[-1] == ( + "configuration", + "load_configuration", + {"config_path": str(destination.resolve())}, + ) + coordinator.succeed( + { + "document_type": document_type, + "config": source_payload, + "source_name": str(destination), + "source_sha256": sha256_bytes(expected_bytes), + } + ) + assert controller.get_document_kind() == document_type + assert controller.document is not None + assert controller.document.source_path == destination.resolve() + + +def test_sweep_draft_composes_the_global_saved_document_and_validation_snapshot( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, coordinator = configured_controller(tmp_path) + workspace = controller.workspace + assert workspace is not None + sweep = controller.sweep_draft + + assert controller.get_sweep_draft() is sweep + assert controller.property("sweepDraft") is sweep + assert controller.new_sweep() + assert controller.get_document_kind() == "model_sweep" + assert controller.get_locally_valid() + assert controller.get_dirty() + assert controller.document is not None + assert controller.document.payload == sweep.payload() + + destination = workspace.configs / "sweep.yaml" + assert controller.request_save_as() + assert controller.save_path_selected(str(destination)) + expected_bytes = controller.document.yaml_bytes + assert coordinator.calls[-1] == ( + "configuration", + "validate_configuration", + { + "yaml_text": expected_bytes.decode("utf-8"), + "source_name": str(destination), + "expected_document_type": "model_sweep", + }, + ) + coordinator.succeed({"document_type": "model_sweep"}) + + snapshot = controller.execution_snapshot(expected_document_type="model_sweep") + assert snapshot.path == destination.resolve() + assert snapshot.yaml_bytes == expected_bytes + assert not controller.get_dirty() + + assert sweep.set_reference_model("pr") + assert controller.get_dirty() + assert controller.document.payload["backend"]["reference_model"] == "pr" + with pytest.raises(ConfigDocumentError, match="save the current configuration changes"): + controller.execution_snapshot(expected_document_type="model_sweep") + + assert controller.request_validation() + changed_bytes = controller.document.yaml_bytes + assert coordinator.calls[-1] == ( + "configuration", + "validate_configuration", + { + "yaml_text": changed_bytes.decode("utf-8"), + "source_name": str(destination), + "expected_document_type": "model_sweep", + }, + ) + coordinator.succeed({"document_type": "model_sweep"}) + assert controller.get_worker_validation_state() == "valid" + + assert sweep.set_reference_model("heos") + assert not controller.get_dirty() + assert controller.execution_snapshot(expected_document_type="model_sweep") == snapshot + + committed_preview = controller.get_yaml_preview() + assert sweep.begin_add_comparison() + assert controller.get_yaml_preview() == committed_preview + assert controller.get_blocking_section() == "sweep" + assert not controller.get_can_validate() + assert not controller.request_save() + assert not controller.clear_document(discard_confirmed=True) + with pytest.raises(ConfigDocumentError, match="complete the configuration form"): + controller.execution_snapshot(expected_document_type="model_sweep") + + assert sweep.cancel_comparison() + assert controller.execution_snapshot(expected_document_type="model_sweep") == snapshot + + +def test_preparation_creation_uses_the_global_document_lifecycle( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, _coordinator = configured_controller(tmp_path) + workspace = controller.workspace + assert workspace is not None + preparation = controller.preparation_draft + + assert controller.new_preparation() + assert controller.get_document_kind() == "preparation" + assert controller.get_locally_valid() + assert controller.get_dirty() + assert not preparation.get_dirty() + assert controller.document is not None + assert controller.document.payload == preparation.payload() + assert controller.document.yaml_bytes == serialize_configuration(preparation_payload()) + assert controller.get_default_save_path() == str(workspace.configs / "preparation.yaml") + assert controller.get_status_message().startswith("New ML Preparation") + + assert not controller.new_sweep() + assert controller.get_document_kind() == "preparation" + assert controller.new_sweep(discard_confirmed=True) + assert controller.get_document_kind() == "model_sweep" + + +def test_preparation_role_draft_composes_and_restores_the_exact_saved_document( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, coordinator = configured_controller(tmp_path) + workspace = controller.workspace + assert workspace is not None + preparation = controller.preparation_draft + + assert controller.get_preparation_draft() is preparation + assert controller.property("preparationDraft") is preparation + assert controller.open_document(new_document(preparation_payload())) + assert controller.get_document_kind() == "preparation" + assert controller.get_locally_valid() + assert not preparation.get_dirty() + assert controller.document is not None + assert controller.document.payload == preparation.payload() + + destination = workspace.configs / "preparation.yaml" + assert controller.request_save_as() + assert controller.save_path_selected(str(destination)) + saved_bytes = controller.document.yaml_bytes + coordinator.succeed({"document_type": "preparation"}) + snapshot = controller.execution_snapshot(expected_document_type="preparation") + assert snapshot.path == destination.resolve() + assert snapshot.yaml_bytes == saved_bytes + assert not controller.get_dirty() + + assert preparation.set_allow_partial_sweep(True) + assert controller.get_dirty() + assert controller.document.payload["source_policy"] == {"allow_partial_sweep": True} + with pytest.raises(ConfigDocumentError, match="save the current configuration changes"): + controller.execution_snapshot(expected_document_type="preparation") + + assert preparation.set_allow_partial_sweep(False) + assert not controller.get_dirty() + assert controller.execution_snapshot(expected_document_type="preparation") == snapshot + + assert preparation.set_role_selected("target", "specific_enthalpy", False) + assert not controller.get_locally_valid() + assert controller.get_blocking_section() == "preparation" + assert controller.get_blocking_field() == "preparation.targets" + assert not controller.get_can_save() + assert controller.get_yaml_preview() == "" + assert preparation.set_role_selected("target", "specific_enthalpy", True) + assert controller.get_locally_valid() + assert controller.execution_snapshot(expected_document_type="preparation") == snapshot + + +def test_preparation_output_and_quality_drafts_compose_into_global_document( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, _coordinator = configured_controller(tmp_path) + preparation = controller.preparation_draft + assert controller.open_document(new_document(preparation_payload())) + assert controller.document is not None + original = controller.document.payload + original_yaml = controller.document.yaml_bytes + + assert preparation.set_array_outputs_enabled(True) + assert preparation.set_array_dtype("float64") + assert preparation.set_include_auxiliary(True) + assert preparation.set_matrix_enabled(True) + assert preparation.set_correlation_threshold("0.98") + + assert controller.get_locally_valid() + assert controller.get_dirty() + assert controller.document.payload["outputs"]["arrays"] == { + "formats": ["npz"], + "dtype": "float64", + "include_auxiliary": True, + } + assert controller.document.payload["quality"]["matrix_diagnostics"] == { + "correlation_threshold": 0.98, + "near_constant_relative_spread": 1e-12, + } + + assert preparation.set_array_outputs_enabled(False) + assert preparation.set_matrix_enabled(False) + assert controller.document.payload == original + assert controller.document.yaml_bytes == original_yaml + assert not preparation.get_dirty() + + +def test_committed_preparation_scenario_composes_into_global_document( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, _coordinator = configured_controller(tmp_path) + preparation = controller.preparation_draft + assert controller.open_document(new_document(preparation_payload())) + assert controller.document is not None + original_payload = controller.document.payload + original_yaml = controller.document.yaml_bytes + + assert preparation.begin_add_scenario() + active = preparation.get_active_scenario_draft() + assert active is not None + assert active.set_name("all") + + assert controller.document.payload == original_payload + assert controller.document.yaml_bytes == original_yaml + assert not preparation.get_dirty() + + assert preparation.commit_scenario() + + assert controller.document.payload["scenarios"] == [ + { + "name": "all", + "kind": "unsplit", + "partitions": {"all": 1.0}, + "holdouts": {}, + "transformations": [], + } + ] + assert controller.document.yaml_bytes != original_yaml + assert preparation.get_dirty() + assert controller.get_dirty() + + +def test_active_preparation_scenario_blocks_all_global_document_actions( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, coordinator = configured_controller(tmp_path) + workspace = controller.workspace + assert workspace is not None + assert controller.open_document(new_document(preparation_payload())) + destination = workspace.configs / "preparation.yaml" + assert controller.request_save_as() + assert controller.save_path_selected(str(destination)) + coordinator.succeed({"document_type": "preparation"}) + snapshot = controller.execution_snapshot(expected_document_type="preparation") + requests_before_edit = len(coordinator.calls) + + preparation = controller.preparation_draft + assert preparation.begin_add_scenario() + active = preparation.get_active_scenario_draft() + assert active is not None + assert active.set_name("not a slug") + + assert controller.get_locally_valid() + assert controller.get_blocking_section() == "preparation" + assert controller.get_blocking_field() == "preparation.scenario.active.name" + assert "safe slugs" in controller.get_blocking_issue() + assert controller.get_worker_validation_state() == "blocked" + assert not controller.get_can_validate() + assert not controller.get_can_save() + assert not controller.get_can_create() + assert not controller.get_can_import() + assert not controller.request_validation() + assert not controller.request_save() + assert not controller.request_save_as() + assert not controller.reload_source(discard_confirmed=True) + assert not controller.new_dataset("property_table", discard_confirmed=True) + assert not controller.new_sweep(discard_confirmed=True) + assert not controller.new_preparation(discard_confirmed=True) + assert not controller.import_dataset("dataset.yaml", discard_confirmed=True) + assert not controller.import_configuration("config.yaml", discard_confirmed=True) + assert not controller.open_document(new_document(payload())) + assert not controller.clear_document(discard_confirmed=True) + assert len(coordinator.calls) == requests_before_edit + with pytest.raises(ConfigDocumentError, match="complete the configuration form"): + controller.execution_snapshot(expected_document_type="preparation") + + replacement = initialize_workspace(tmp_path / "replacement") + controller.set_workspace(replacement) + assert controller.workspace == workspace + assert controller.get_document_kind() == "preparation" + + assert preparation.cancel_scenario() + assert controller.get_blocking_section() == "none" + assert controller.execution_snapshot(expected_document_type="preparation") == snapshot + + +def test_save_as_destination_callback_cannot_bypass_active_preparation_edit( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + controller, coordinator = configured_controller(tmp_path) + workspace = controller.workspace + assert workspace is not None + assert controller.open_document(new_document(preparation_payload())) + assert controller.request_save_as() + assert controller.preparation_draft.begin_add_scenario() + + destination = workspace.configs / "must-not-exist.yaml" + assert not controller.save_path_selected(str(destination)) + + assert not destination.exists() + assert len(coordinator.calls) == 1 diff --git a/tests/test_app_config_document.py b/tests/test_app_config_document.py index 7166b8f..982e404 100644 --- a/tests/test_app_config_document.py +++ b/tests/test_app_config_document.py @@ -2,7 +2,9 @@ import hashlib import os +from collections.abc import Callable from pathlib import Path +from typing import Any import pytest import yaml @@ -10,19 +12,27 @@ import carnopy.app.config_document as config_documents from carnopy.app.config_document import ( ConfigDocumentError, - DatasetConfigDocument, + ConfigurationDocument, ExternalModificationError, document_from_worker_payload, new_document, replace_config_atomic, + serialize_configuration, serialize_dataset_config, + serialize_preparation_config, + serialize_sweep_config, source_matches, write_new_config, ) -from carnopy.config.io import load_config_bytes +from carnopy.config.io import load_config_bytes, load_sweep_config_bytes +from carnopy.preparation.models import load_preparation_config_bytes from carnopy.templates import template_text +def test_dataset_specific_document_alias_is_removed() -> None: + assert not hasattr(config_documents, "DatasetConfigDocument") + + @pytest.mark.parametrize( "mode", ["property_table", "saturation_table", "vapor_mass_fraction_table"], @@ -155,6 +165,229 @@ def test_dataset_output_formats_use_canonical_order() -> None: assert serialized["outputs"]["dataset_formats"] == ["csv", "parquet"] +@pytest.mark.parametrize( + ("template", "serializer", "schema_version"), + [ + ("property_table", serialize_dataset_config, 2), + ("model_sweep", serialize_sweep_config, 2), + ("preparation", serialize_preparation_config, 1), + ], +) +def test_configuration_documents_serialize_deterministically_by_discriminator( + template: str, + serializer: Callable[[dict[str, Any]], bytes], + schema_version: int, +) -> None: + payload = yaml.safe_load(template_text(template)) + + first = serializer(payload) + second = serialize_configuration(payload) + + assert first == second + assert yaml.safe_load(first)["document_type"] == payload["document_type"] + assert yaml.safe_load(first)["schema_version"] == schema_version + document = new_document(payload) + assert isinstance(document, ConfigurationDocument) + assert document.document_type == payload["document_type"] + assert document.yaml_bytes == first + + +def test_sweep_serialization_preserves_complete_current_schema() -> None: + payload = yaml.safe_load(template_text("model_sweep")) + payload["grid"] = { + "pressure": payload["grid"]["pressure"], + "temperature": payload["grid"]["temperature"], + } + payload["outputs"]["dataset_formats"] = ["parquet", "csv"] + payload["comparison_plots"] = { + "format": "svg", + "plots": [ + { + "name": "density-comparison", + "kind": "property_comparison", + "fluid": "Propane", + "property": "mass_density", + "x": "temperature", + "group_by": "pressure", + "filters": {"phase": "gas", "pressure": 100000.0}, + "models": ["srk", "heos", "pr"], + "value_scale": "log", + "format": "pdf", + }, + { + "name": "density-delta", + "kind": "property_delta", + "fluid": "Propane", + "property": "mass_density", + "x": "pressure", + "models": ["pr", "srk"], + "delta_metric": "signed_absolute_difference", + }, + ], + } + + first = serialize_sweep_config(payload) + second = serialize_sweep_config(payload) + expected = load_sweep_config_bytes( + yaml.safe_dump(payload).encode("utf-8"), + source_name="rich-sweep-input.yaml", + ).model + loaded = load_sweep_config_bytes(first, source_name="rich-sweep-output.yaml").model + serialized = yaml.safe_load(first) + + assert first == second + assert loaded == expected + assert list(serialized) == [ + "schema_version", + "document_type", + "backend", + "mode", + "fluids", + "grid", + "properties", + "outputs", + "comparison_plots", + ] + assert list(serialized["grid"]) == ["temperature", "pressure"] + assert serialized["outputs"]["dataset_formats"] == ["csv", "parquet"] + assert list(serialized["comparison_plots"]["plots"][0]) == [ + "name", + "kind", + "fluid", + "property", + "x", + "group_by", + "filters", + "models", + "delta_metric", + "value_scale", + "format", + ] + + +def test_preparation_serialization_preserves_complete_current_schema() -> None: + payload = { + "schema_version": 1, + "document_type": "preparation", + "source_policy": {"allow_partial_sweep": True}, + "features": { + "numeric": ["temperature", "pressure"], + "derived": ["specific_volume"], + }, + "categorical_features": [ + { + "field": "phase", + "encoding": "one_hot", + "categories": ["gas", "liquid"], + }, + {"field": "fluid", "encoding": "one_hot", "categories": "observed"}, + ], + "targets": ["mass_density"], + "auxiliary": ["backend_model", "run_id"], + "scenarios": [ + { + "name": "stratified", + "kind": "stratified_hash", + "seed": 9, + "partitions": {"test": 0.2, "train": 0.7, "validation": 0.1}, + "strata": { + "numeric_bins": {"temperature": [280.0, 320.0]}, + "categorical": ["phase"], + }, + "transformations": [ + {"field": "pressure", "methods": ["log10", "standard"]}, + {"field": "temperature", "methods": ["minmax"]}, + ], + }, + { + "name": "coordinate-block", + "kind": "coordinate_block", + "holdouts": { + "test": { + "pressure": {"min": 100000.0, "max": 200000.0}, + "temperature": {"min": 280.0, "max": 300.0}, + } + }, + "remainder": "train", + "transformations": [{"field": "pressure", "methods": ["robust"]}], + }, + ], + "quality": { + "matrix_diagnostics": { + "correlation_threshold": 0.99, + "near_constant_relative_spread": 1e-10, + }, + "baseline_diagnostics": { + "models": ["hist_gradient_boosting", "dummy_mean", "ridge"], + "random_seed": 7, + "ridge_alpha": 0.5, + "histogram_max_iterations": 50, + }, + }, + "outputs": { + "formats": ["parquet"], + "parquet": True, + "arrays": { + "formats": ["safetensors", "npy", "npz"], + "dtype": "float64", + "include_auxiliary": True, + }, + }, + } + + first = serialize_preparation_config(payload) + second = serialize_preparation_config(payload) + expected = load_preparation_config_bytes( + yaml.safe_dump(payload).encode("utf-8"), + source_name="rich-preparation-input.yaml", + ).model + loaded = load_preparation_config_bytes( + first, + source_name="rich-preparation-output.yaml", + ).model + serialized = yaml.safe_load(first) + + assert first == second + assert loaded == expected + assert list(serialized) == [ + "schema_version", + "document_type", + "source_policy", + "features", + "categorical_features", + "targets", + "auxiliary", + "scenarios", + "quality", + "outputs", + ] + assert [item["name"] for item in serialized["scenarios"]] == [ + "stratified", + "coordinate-block", + ] + assert serialized["scenarios"][0]["transformations"] == [ + {"field": "pressure", "methods": ["log10", "standard"]}, + {"field": "temperature", "methods": ["minmax"]}, + ] + assert serialized["outputs"]["arrays"]["formats"] == [ + "npy", + "npz", + "safetensors", + ] + + +@pytest.mark.parametrize("document_type", [None, "sweep", "PREPARATION"]) +def test_generic_document_rejects_missing_or_unknown_discriminator( + document_type: str | None, +) -> None: + payload: dict[str, object] = {"schema_version": 2} + if document_type is not None: + payload["document_type"] = document_type + + with pytest.raises(ConfigDocumentError, match="document_type"): + new_document(payload) + + def test_document_tracks_unsaved_and_dirty_state_without_exposing_mutable_payload() -> None: payload = yaml.safe_load(template_text("property_table")) document = new_document(payload) @@ -191,6 +424,27 @@ def test_worker_document_identity_and_workspace_ownership(tmp_path: Path) -> Non assert source_matches(source, document.source_sha256 or "") +def test_worker_document_rejects_an_inconsistent_top_level_discriminator( + tmp_path: Path, +) -> None: + configs = tmp_path / "configs" + configs.mkdir() + source = configs / "sweep.yaml" + content = serialize_sweep_config(yaml.safe_load(template_text("model_sweep"))) + source.write_bytes(content) + + with pytest.raises(ConfigDocumentError, match="document type is inconsistent"): + document_from_worker_payload( + { + "document_type": "preparation", + "config": yaml.safe_load(content), + "source_name": str(source), + "source_sha256": hashlib.sha256(content).hexdigest(), + }, + configs_root=configs, + ) + + def test_new_and_atomic_save_refuse_overwrite_and_external_modification(tmp_path: Path) -> None: configs = tmp_path / "configs" configs.mkdir() @@ -266,7 +520,7 @@ def test_save_rejects_invalid_or_escaping_destinations(tmp_path: Path) -> None: def test_mark_saved_updates_document_identity(tmp_path: Path) -> None: payload = yaml.safe_load(template_text("property_table")) - document = DatasetConfigDocument(payload, imported=True) + document = ConfigurationDocument(payload, imported=True) destination = tmp_path / "dataset.yaml" content = document.yaml_bytes @@ -279,11 +533,23 @@ def test_mark_saved_updates_document_identity(tmp_path: Path) -> None: assert not document.dirty -def test_execution_snapshot_requires_exact_saved_workspace_bytes(tmp_path: Path) -> None: +@pytest.mark.parametrize( + ("template", "document_type"), + [ + ("property_table", "dataset"), + ("model_sweep", "model_sweep"), + ("preparation", "preparation"), + ], +) +def test_execution_snapshot_requires_exact_saved_workspace_bytes( + tmp_path: Path, + template: str, + document_type: str, +) -> None: configs = tmp_path / "configs" configs.mkdir() - document = new_document(yaml.safe_load(template_text("property_table"))) - destination = configs / "dataset.yaml" + document = new_document(yaml.safe_load(template_text(template))) + destination = configs / f"{document_type}.yaml" content = document.yaml_bytes destination.write_bytes(content) document.mark_saved(destination, content) @@ -293,6 +559,7 @@ def test_execution_snapshot_requires_exact_saved_workspace_bytes(tmp_path: Path) assert snapshot.path == destination.resolve() assert snapshot.yaml_bytes == content assert snapshot.sha256 == hashlib.sha256(content).hexdigest() + assert snapshot.document_type == document_type destination.write_bytes(content + b"# changed\n") with pytest.raises(ExternalModificationError, match="outside Carnopy"): diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 28b9f6c..13a4c68 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -27,6 +27,7 @@ from carnopy.app.draft_models import DraftItem from carnopy.app.jobs import JobStore from carnopy.app.request_coordinator import DesktopRequestCoordinator +from carnopy.app.scenario_draft import ScenarioDraft from carnopy.app.workspace import initialize_workspace from carnopy.app.workspace_controller import ( MAX_RECENT_WORKSPACES, @@ -91,20 +92,33 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity assert desktop.request_coordinator.client is desktop.client assert desktop.dataset_draft.parent() is desktop assert desktop.visualization_draft.parent() is desktop - assert desktop.dataset_config_controller.parent() is desktop - assert desktop.dataset_config_controller.coordinator is desktop.request_coordinator - assert desktop.dataset_config_controller.dataset_draft is desktop.dataset_draft - assert desktop.dataset_config_controller.visualization_draft is desktop.visualization_draft + assert desktop.configuration_controller.parent() is desktop + assert desktop.configuration_controller.coordinator is desktop.request_coordinator + assert desktop.configuration_controller.dataset_draft is desktop.dataset_draft + assert desktop.configuration_controller.visualization_draft is desktop.visualization_draft + assert desktop.configuration_controller.sweep_draft.parent() is desktop.configuration_controller + assert ( + desktop.configuration_controller.preparation_draft.parent() + is desktop.configuration_controller + ) assert desktop.execution_controller.parent() is desktop assert desktop.execution_controller.coordinator is desktop.request_coordinator - assert desktop.execution_controller.config_controller is desktop.dataset_config_controller + assert desktop.execution_controller.config_controller is desktop.configuration_controller assert desktop.activity_controller.parent() is desktop assert desktop.activity_controller.coordinator is desktop.request_coordinator assert desktop.sweep_workflow_controller.parent() is desktop assert desktop.sweep_workflow_controller.kind == "sweep" + assert ( + desktop.sweep_workflow_controller.configuration_controller + is desktop.configuration_controller + ) assert desktop.preparation_workflow_controller.parent() is desktop assert desktop.preparation_workflow_controller.kind == "preparation" assert desktop.preparation_workflow_controller.inspection is desktop.inspection_controller + assert ( + desktop.preparation_workflow_controller.configuration_controller + is desktop.configuration_controller + ) assert desktop.configured_plot_results_controller.parent() is desktop assert desktop.configured_plot_results_controller.activity is desktop.activity_controller assert desktop.session_plot_controller.parent() is desktop @@ -118,8 +132,22 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity assert desktop.property("qmlSettings") is desktop.qml_settings assert desktop.property("datasetDraft") is desktop.dataset_draft assert desktop.property("visualizationDraft") is desktop.visualization_draft - assert desktop.property("datasetConfigController") is desktop.dataset_config_controller + assert desktop.property("configurationController") is desktop.configuration_controller + assert ( + desktop.configuration_controller.property("sweepDraft") + is desktop.configuration_controller.sweep_draft + ) + assert ( + desktop.configuration_controller.property("preparationDraft") + is desktop.configuration_controller.preparation_draft + ) + assert not hasattr(desktop, "dataset_config_controller") + assert desktop.property("datasetConfigController") is None assert desktop.property("executionController") is desktop.execution_controller + assert desktop.property("sweepWorkflowController") is desktop.sweep_workflow_controller + assert ( + desktop.property("preparationWorkflowController") is desktop.preparation_workflow_controller + ) assert desktop.property("activityController") is desktop.activity_controller assert ( desktop.property("configuredPlotResultsController") @@ -184,223 +212,1441 @@ def test_qml_shutdown_requires_explicit_dirty_discard_confirmation( ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) - confirmations: list[str] = [] - close_requests: list[str] = [] - desktop.shutdownConfirmationRequested.connect(lambda: confirmations.append("confirm")) - desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + confirmations: list[str] = [] + close_requests: list[str] = [] + desktop.shutdownConfirmationRequested.connect(lambda: confirmations.append("confirm")) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + monkeypatch.setattr( + desktop.configuration_controller, + "needs_discard_confirmation", + lambda: True, + ) + + assert not desktop.request_shutdown() + assert confirmations == ["confirm"] + assert not desktop.confirm_shutdown(False) + assert close_requests == [] + assert not desktop.request_shutdown() + assert confirmations == ["confirm", "confirm"] + assert desktop.confirm_shutdown(True) + assert close_requests == ["close"] + assert desktop.request_shutdown() + + +def test_qml_shutdown_refuses_an_active_worker_without_closing( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + close_requests: list[str] = [] + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + + with monkeypatch.context() as scoped: + scoped.setattr( + type(desktop.request_coordinator), + "is_busy", + property(lambda _self: True), + ) + assert not desktop.request_shutdown() + + assert close_requests == [] + assert "active worker request" in desktop.get_workspace_error_message() + assert desktop.shutdown() + + +def test_qml_shutdown_cancels_generation_then_closes_after_safe_completion( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + confirmations: list[tuple[str, str]] = [] + cancellations: list[str] = [] + close_requests: list[str] = [] + desktop.busyShutdownConfirmationRequested.connect( + lambda mode, message: confirmations.append((mode, message)) + ) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + desktop.request_coordinator._active_session = SimpleNamespace( + owner="execution", + request_type="generate_dataset", + ) + monkeypatch.setattr(desktop.execution_controller, "get_can_cancel", lambda: True) + monkeypatch.setattr( + desktop.execution_controller, + "cancel", + lambda: cancellations.append("cancel") or True, + ) + + assert not desktop.request_shutdown() + assert confirmations == [ + ( + "cancel_generation", + "Dataset generation is active. Cancel it cooperatively and close Carnopy " + "after the worker and activity record finish safely?", + ) + ] + assert not desktop.confirm_busy_shutdown(False) + assert cancellations == [] + + assert not desktop.request_shutdown() + assert desktop.confirm_busy_shutdown(True) + assert cancellations == ["cancel"] + desktop.request_coordinator._active_session = None + desktop._complete_busy_shutdown() + + assert close_requests == ["close"] + + +def test_qml_shutdown_cancels_sweep_then_closes_after_safe_completion( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + confirmations: list[tuple[str, str]] = [] + cancellations: list[str] = [] + close_requests: list[str] = [] + desktop.busyShutdownConfirmationRequested.connect( + lambda mode, message: confirmations.append((mode, message)) + ) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + desktop.request_coordinator._active_session = SimpleNamespace( + owner="sweep", + request_type="execute_sweep", + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "get_cancellation_available", + lambda: True, + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "cancel", + lambda: cancellations.append("cancel") or True, + ) + + assert not desktop.request_shutdown() + assert confirmations == [ + ( + "cancel_sweep", + "Model Sweep execution is active. Cancel it cooperatively and close Carnopy " + "after the worker and activity record finish safely?", + ) + ] + assert desktop.confirm_busy_shutdown(True) + assert cancellations == ["cancel"] + desktop.request_coordinator._active_session = None + desktop._complete_busy_shutdown() + + assert close_requests == ["close"] + + +def test_qml_shutdown_cancels_preparation_then_closes_after_safe_completion( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + confirmations: list[tuple[str, str]] = [] + cancellations: list[str] = [] + close_requests: list[str] = [] + desktop.busyShutdownConfirmationRequested.connect( + lambda mode, message: confirmations.append((mode, message)) + ) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + desktop.request_coordinator._active_session = SimpleNamespace( + owner="preparation", + request_type="execute_preparation", + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "get_cancellation_available", + lambda: True, + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "cancel", + lambda: cancellations.append("cancel") or True, + ) + + assert not desktop.request_shutdown() + assert confirmations == [ + ( + "cancel_preparation", + "ML Preparation execution is active. Cancel it cooperatively and close Carnopy " + "after the worker and activity record finish safely?", + ) + ] + assert desktop.confirm_busy_shutdown(True) + assert cancellations == ["cancel"] + desktop.request_coordinator._active_session = None + desktop._complete_busy_shutdown() + + assert close_requests == ["close"] + + +def test_plot_cleanup_failure_aborts_pending_busy_shutdown( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + confirmations: list[tuple[str, str]] = [] + close_requests: list[str] = [] + desktop.busyShutdownConfirmationRequested.connect( + lambda mode, message: confirmations.append((mode, message)) + ) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + desktop.request_coordinator._active_session = SimpleNamespace( + owner="plot", + request_type="render_plot", + ) + monkeypatch.setattr(desktop.session_plot_controller, "get_can_force_stop", lambda: True) + monkeypatch.setattr(desktop.session_plot_controller, "force_stop", lambda: True) + + assert not desktop.request_shutdown() + assert confirmations[0][0] == "force_stop_plot" + assert desktop.confirm_busy_shutdown(True) + desktop.request_coordinator._active_session = None + monkeypatch.setattr( + desktop.session_plot_controller, + "get_cleanup_issue", + lambda: "owned staging cleanup failed", + ) + desktop._complete_busy_shutdown() + + assert close_requests == [] + assert "cleanup failed" in desktop.get_workspace_error_message() + assert desktop.shutdown() + + +def test_qml_shutdown_explicitly_cancels_transient_plot_edits_before_close( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + confirmations: list[str] = [] + close_requests: list[str] = [] + cancellations: list[str] = [] + active = {"value": True} + desktop.transientEditShutdownConfirmationRequested.connect(confirmations.append) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + monkeypatch.setattr(desktop, "get_has_active_plot_edit", lambda: False) + monkeypatch.setattr(desktop, "get_has_session_plot_edit", lambda: active["value"]) + + def cancel_session_edit() -> bool: + cancellations.append("session") + active["value"] = False + return True + + monkeypatch.setattr( + desktop.session_plot_controller, + "cancel_edit", + cancel_session_edit, + ) + + assert not desktop.request_shutdown() + assert confirmations == [ + "A session plot edit is still open. Cancel the edit and close Carnopy?" + ] + assert not desktop.confirm_transient_edit_shutdown(False) + assert cancellations == [] + assert close_requests == [] + assert not desktop.request_shutdown() + assert confirmations == [ + "A session plot edit is still open. Cancel the edit and close Carnopy?", + "A session plot edit is still open. Cancel the edit and close Carnopy?", + ] + assert desktop.confirm_transient_edit_shutdown(True) + assert cancellations == ["session"] + assert close_requests == ["close"] + + +def test_qml_shutdown_explicitly_cancels_a_transient_sweep_edit_before_close( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + confirmations: list[str] = [] + close_requests: list[str] = [] + cancellations: list[str] = [] + active = {"value": True} + desktop.transientEditShutdownConfirmationRequested.connect(confirmations.append) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + monkeypatch.setattr(desktop, "get_has_active_sweep_edit", lambda: active["value"]) + + def cancel_comparison() -> bool: + cancellations.append("comparison") + active["value"] = False + return True + + monkeypatch.setattr( + desktop.configuration_controller.sweep_draft, + "cancel_comparison", + cancel_comparison, + ) + + assert not desktop.request_shutdown() + assert confirmations == [ + "A Sweep comparison edit is still open. Cancel the edit and close Carnopy?" + ] + assert not desktop.confirm_transient_edit_shutdown(False) + assert cancellations == [] + assert close_requests == [] + assert not desktop.request_shutdown() + assert confirmations == [ + "A Sweep comparison edit is still open. Cancel the edit and close Carnopy?", + "A Sweep comparison edit is still open. Cancel the edit and close Carnopy?", + ] + assert desktop.confirm_transient_edit_shutdown(True) + assert cancellations == ["comparison"] + assert close_requests == ["close"] + + +def test_qml_shutdown_explicitly_cancels_a_transient_preparation_edit_before_close( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + confirmations: list[str] = [] + close_requests: list[str] = [] + cancellations: list[str] = [] + active = {"value": True} + desktop.transientEditShutdownConfirmationRequested.connect(confirmations.append) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + monkeypatch.setattr( + desktop, + "get_has_active_preparation_edit", + lambda: active["value"], + ) + + def cancel_scenario() -> bool: + cancellations.append("scenario") + active["value"] = False + return True + + monkeypatch.setattr( + desktop.configuration_controller.preparation_draft, + "cancel_scenario", + cancel_scenario, + ) + + assert not desktop.request_shutdown() + assert confirmations == [ + "A Preparation scenario edit is still open. Cancel the edit and close Carnopy?" + ] + assert not desktop.confirm_transient_edit_shutdown(False) + assert cancellations == [] + assert close_requests == [] + assert not desktop.request_shutdown() + assert confirmations == [ + "A Preparation scenario edit is still open. Cancel the edit and close Carnopy?", + "A Preparation scenario edit is still open. Cancel the edit and close Carnopy?", + ] + assert desktop.confirm_transient_edit_shutdown(True) + assert cancellations == ["scenario"] + assert close_requests == ["close"] + + +def test_busy_shutdown_confirmation_is_bound_to_the_exact_worker_session( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + cancellations: list[str] = [] + confirmations: list[str] = [] + close_requests: list[str] = [] + desktop.busyShutdownConfirmationRequested.connect( + lambda mode, _message: confirmations.append(mode) + ) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + first = SimpleNamespace(owner="sweep", request_type="execute_sweep") + replacement = SimpleNamespace( + owner="preparation", + request_type="execute_preparation", + ) + desktop.request_coordinator._active_session = first + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "cancel", + lambda: cancellations.append("sweep") or True, + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "get_cancellation_available", + lambda: True, + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "cancel", + lambda: cancellations.append("preparation") or True, + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "get_cancellation_available", + lambda: True, + ) + + assert not desktop.request_shutdown() + assert confirmations == ["cancel_sweep"] + desktop.request_coordinator._active_session = replacement + assert not desktop.confirm_busy_shutdown(True) + assert cancellations == [] + assert "worker request changed" in desktop.get_workspace_error_message() + + assert not desktop.request_shutdown() + assert confirmations == ["cancel_sweep", "cancel_preparation"] + assert desktop.confirm_busy_shutdown(True) + assert cancellations == ["preparation"] + desktop.request_coordinator._active_session = None + desktop._complete_busy_shutdown() + + assert close_requests == ["close"] + + +def test_pending_busy_shutdown_never_cancels_a_replacement_session( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + cancellations: list[str] = [] + first = SimpleNamespace(owner="sweep", request_type="execute_sweep") + replacement = SimpleNamespace(owner="sweep", request_type="execute_sweep") + desktop.request_coordinator._active_session = first + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "get_cancellation_available", + lambda: False, + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "cancel", + lambda: cancellations.append("cancel") or True, + ) + + assert not desktop.request_shutdown() + assert desktop.confirm_busy_shutdown(True) + assert desktop._pending_busy_shutdown == "sweep_waiting" + desktop.request_coordinator._active_session = replacement + desktop._continue_pending_busy_shutdown() + + assert cancellations == [] + assert desktop._pending_busy_shutdown == "" + assert "Another worker request started" in desktop.get_workspace_error_message() + desktop.request_coordinator._active_session = None + assert desktop.shutdown() + + +def test_protected_finalization_waits_without_offering_cancellation( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + confirmations: list[str] = [] + close_requests: list[str] = [] + session = SimpleNamespace( + owner="preparation", + request_type="execute_preparation", + termination_protected=True, + ) + desktop.request_coordinator._active_session = session + desktop.busyShutdownConfirmationRequested.connect( + lambda mode, _message: confirmations.append(mode) + ) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + + assert not desktop.request_shutdown() + assert confirmations == [] + assert close_requests == [] + assert desktop._pending_busy_shutdown == "protected_finalization" + assert "Finalizing safely" in desktop.get_workspace_error_message() + + desktop.request_coordinator._active_session = None + desktop._complete_busy_shutdown() + assert close_requests == ["close"] + + +def test_shutdown_sequences_worker_transient_edit_and_dirty_document( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + busy_confirmations: list[str] = [] + transient_confirmations: list[str] = [] + dirty_confirmations: list[str] = [] + cancellations: list[str] = [] + close_requests: list[str] = [] + edit = QObject() + edit_active = {"value": True} + session = SimpleNamespace(owner="sweep", request_type="execute_sweep") + desktop.request_coordinator._active_session = session + desktop.busyShutdownConfirmationRequested.connect( + lambda mode, _message: busy_confirmations.append(mode) + ) + desktop.transientEditShutdownConfirmationRequested.connect(transient_confirmations.append) + desktop.shutdownConfirmationRequested.connect(lambda: dirty_confirmations.append("dirty")) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "get_cancellation_available", + lambda: True, + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "cancel", + lambda: cancellations.append("worker") or True, + ) + monkeypatch.setattr( + desktop, + "get_has_active_sweep_edit", + lambda: edit_active["value"], + ) + monkeypatch.setattr( + desktop.configuration_controller.sweep_draft, + "get_active_comparison_draft", + lambda: edit if edit_active["value"] else None, + ) + + def cancel_edit() -> bool: + cancellations.append("edit") + edit_active["value"] = False + return True + + monkeypatch.setattr( + desktop.configuration_controller.sweep_draft, + "cancel_comparison", + cancel_edit, + ) + monkeypatch.setattr( + desktop.configuration_controller, + "needs_discard_confirmation", + lambda: True, + ) + + assert not desktop.request_shutdown() + assert busy_confirmations == ["cancel_sweep"] + assert desktop.confirm_busy_shutdown(True) + assert cancellations == ["worker"] + + desktop.request_coordinator._active_session = None + desktop._complete_busy_shutdown() + assert len(transient_confirmations) == 1 + assert dirty_confirmations == [] + assert close_requests == [] + + assert desktop.confirm_transient_edit_shutdown(True) + assert cancellations == ["worker", "edit"] + assert dirty_confirmations == ["dirty"] + assert close_requests == [] + + assert desktop.confirm_shutdown(True) + assert close_requests == ["close"] + assert desktop.request_shutdown() + + +def test_shutdown_confirmations_reject_changed_transient_and_document_state( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + first = QObject() + replacement = QObject() + active = {"draft": first} + cancellations: list[str] = [] + monkeypatch.setattr(desktop, "get_has_session_plot_edit", lambda: True) + monkeypatch.setattr( + desktop.session_plot_controller, + "get_active_plot_draft", + lambda: active["draft"], + ) + monkeypatch.setattr( + desktop.session_plot_controller, + "cancel_edit", + lambda: cancellations.append("cancel") or True, + ) + + assert not desktop.request_shutdown() + active["draft"] = replacement + assert not desktop.confirm_transient_edit_shutdown(True) + assert cancellations == [] + assert "unfinished edits changed" in desktop.get_workspace_error_message() + + monkeypatch.setattr(desktop, "get_has_session_plot_edit", lambda: False) + monkeypatch.setattr( + desktop.configuration_controller, + "needs_discard_confirmation", + lambda: True, + ) + assert not desktop.request_shutdown() + desktop._configuration_state_changed() + assert not desktop.confirm_shutdown(True) + assert "configuration changed" in desktop.get_workspace_error_message() + assert not desktop.confirm_shutdown(True) + + monkeypatch.setattr( + desktop.configuration_controller, + "needs_discard_confirmation", + lambda: False, + ) + assert desktop.shutdown() + + +def test_configuration_attention_facade_accepts_only_stable_sections( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + attention: list[tuple[str, str, int]] = [] + desktop.attentionRequested.connect( + lambda section, field, row: attention.append((section, field, row)) + ) + + assert desktop.request_configuration_attention("dataset", "dataset.properties", 2) + assert desktop.request_configuration_attention("sweep", "sweep.backend.reference_model", -1) + assert desktop.request_configuration_attention( + "preparation", + "preparation.scenario.active.name", + -1, + ) + assert desktop.request_configuration_attention("visualization", "plot.name", -1) + assert not desktop.request_configuration_attention("workspace", "dataset.mode", -1) + assert not desktop.request_configuration_attention("dataset", "plot.name", -1) + assert attention == [ + ("dataset", "dataset.properties", 2), + ("sweep", "sweep.backend.reference_model", -1), + ("preparation", "preparation.scenario.active.name", -1), + ("visualization", "plot.name", -1), + ] + assert desktop.shutdown() + + +def test_execution_facade_routes_qml_intent_to_the_authoritative_controller( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + calls: list[str] = [] + monkeypatch.setattr( + desktop.execution_controller, + "validate", + lambda: calls.append("validate") or True, + ) + monkeypatch.setattr( + desktop.execution_controller, + "generate", + lambda: calls.append("generate") or True, + ) + monkeypatch.setattr( + desktop.execution_controller, + "cancel", + lambda: calls.append("cancel") or True, + ) + monkeypatch.setattr( + desktop.execution_controller, + "force_stop", + lambda: calls.append("force_stop") or True, + ) + + assert desktop.request_execution_validation() + assert desktop.request_dataset_generation() + assert desktop.request_execution_cancel() + assert desktop.request_execution_force_stop() + assert calls == ["validate", "generate", "cancel", "force_stop"] + assert desktop.shutdown() + + +def test_workflow_facade_routes_sweep_and_preparation_control( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + calls: list[str] = [] + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "plan", + lambda: calls.append("plan") or True, + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "execute", + lambda: calls.append("execute") or True, + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "cancel", + lambda: calls.append("cancel") or True, + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "force_stop", + lambda: calls.append("force_stop") or True, + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "plan", + lambda: calls.append("preparation_plan") or True, + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "execute", + lambda: calls.append("preparation_execute") or True, + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "cancel", + lambda: calls.append("preparation_cancel") or True, + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "force_stop", + lambda: calls.append("preparation_force_stop") or True, + ) + + assert desktop.request_workflow_plan("sweep") + assert desktop.request_workflow_execute("model_sweep") + assert desktop.request_workflow_cancel("sweep") + assert desktop.request_workflow_force_stop("model_sweep") + assert desktop.request_workflow_plan("preparation") + assert desktop.request_workflow_execute("preparation") + assert desktop.request_workflow_cancel("preparation") + assert desktop.request_workflow_force_stop("preparation") + assert not desktop.request_workflow_execute("unknown") + assert calls == [ + "plan", + "execute", + "cancel", + "force_stop", + "preparation_plan", + "preparation_execute", + "preparation_cancel", + "preparation_force_stop", + ] + assert desktop.shutdown() + + +def test_active_worker_blocks_direct_global_action_slots( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + calls: list[str] = [] + for name in ( + "new_dataset", + "new_sweep", + "new_preparation", + "import_dataset", + "import_configuration", + "request_save", + "request_save_as", + "request_validation", + "reload_source", + "clear_document", + ): + monkeypatch.setattr( + desktop.configuration_controller, + name, + lambda *_args, operation=name: calls.append(operation) or True, + ) + monkeypatch.setattr( + desktop.execution_controller, + "validate", + lambda: calls.append("dataset_validate") or True, + ) + monkeypatch.setattr( + desktop.execution_controller, + "generate", + lambda: calls.append("dataset_generate") or True, + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "plan", + lambda: calls.append("sweep_plan") or True, + ) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "execute", + lambda: calls.append("sweep_execute") or True, + ) + desktop.request_coordinator._active_session = SimpleNamespace( + owner="sweep", + request_type="execute_sweep", + ) + + assert not desktop.request_new_dataset("property_table", True) + assert not desktop.request_new_sweep(True) + assert not desktop.request_new_preparation(True) + assert not desktop.request_import_dataset("dataset.yaml", True) + assert not desktop.request_import_configuration("sweep.yaml", True) + assert not desktop.request_save() + assert not desktop.request_save_as() + assert not desktop.request_validate_configuration() + assert not desktop.request_reload_source(True) + assert not desktop.request_close_configuration(True) + assert not desktop.request_execution_validation() + assert not desktop.request_dataset_generation() + assert not desktop.request_workflow_plan("sweep") + assert not desktop.request_workflow_execute("sweep") + + assert calls == [] + assert "active worker request" in desktop.get_workspace_error_message() + desktop.request_coordinator._active_session = None + assert desktop.shutdown() + + +@pytest.mark.parametrize( + ("workflow", "draft_name", "section", "field"), + [ + ("sweep", "sweep_draft", "sweep", "sweep.comparison.active.name"), + ( + "preparation", + "preparation_draft", + "preparation", + "preparation.scenario.active.name", + ), + ], +) +def test_active_nested_editor_blocks_direct_plan_and_execute_slots( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, + workflow: str, + draft_name: str, + section: str, + field: str, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + draft = getattr(desktop.configuration_controller, draft_name) + active_method = ( + "get_has_active_comparison_edit" if workflow == "sweep" else "get_has_active_scenario_edit" + ) + monkeypatch.setattr(draft, active_method, lambda: True) + monkeypatch.setattr(draft, "get_first_invalid_field", lambda: field) + monkeypatch.setattr(draft, "get_first_invalid_row", lambda: 2) + controller = desktop._workflow_controller(workflow) + assert controller is not None + calls: list[str] = [] + monkeypatch.setattr(controller, "plan", lambda: calls.append("plan") or True) + monkeypatch.setattr(controller, "execute", lambda: calls.append("execute") or True) + attention: list[tuple[str, str, int]] = [] + desktop.attentionRequested.connect( + lambda focus_section, focus_field, row: attention.append((focus_section, focus_field, row)) + ) + + assert not desktop.request_workflow_plan(workflow) + assert not desktop.request_workflow_execute(workflow) + + assert calls == [] + assert attention == [(section, field, 2), (section, field, 2)] + monkeypatch.setattr(draft, active_method, lambda: False) + assert desktop.shutdown() + + +def test_dataset_and_visualization_slots_follow_worker_edit_policy( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + controller = desktop.configuration_controller + monkeypatch.setattr(controller, "get_document_kind", lambda: "dataset") + monkeypatch.setattr(controller, "get_editor_available", lambda: True) + desktop.dataset_draft.mode_choices.replace( + DraftItem(value=value, display=value, canonical=value) + for value in ("property_table", "saturation_table") + ) + monkeypatch.setattr(desktop.dataset_draft, "get_mode_name", lambda: "property_table") + calls: list[tuple[object, ...]] = [] + monkeypatch.setattr( + desktop.dataset_draft, + "set_model_name", + lambda value: calls.append(("model", value)) or True, + ) + monkeypatch.setattr( + desktop.dataset_draft, + "set_output_selected", + lambda value, selected: calls.append(("output", value, selected)) or True, + ) + monkeypatch.setattr( + desktop.visualization_draft, + "set_enabled", + lambda enabled: calls.append(("visualization", enabled)) or True, + ) + + desktop.request_coordinator._active_session = SimpleNamespace( + owner="configuration", + request_type="validate_configuration", + ) + desktop.request_dataset_model_change("pr") + desktop.request_dataset_output_selection("csv", False) + desktop.request_visualization_enabled(True) + assert not desktop.request_dataset_mode_change("saturation_table") + assert calls == [] + + desktop.request_coordinator._active_session = SimpleNamespace( + owner="execution", + request_type="generate_dataset", + ) + desktop.request_dataset_model_change("pr") + desktop.request_dataset_output_selection("csv", False) + desktop.request_visualization_enabled(True) + assert desktop.request_dataset_mode_change("saturation_table") + + assert calls == [ + ("model", "pr"), + ("output", "csv", False), + ("visualization", True), + ] + desktop.cancel_dataset_decision() + desktop.request_coordinator._active_session = None + assert desktop.shutdown() + + +def test_workflow_creation_and_generic_open_facade_use_global_configuration_lifecycle( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + calls: list[tuple[object, ...]] = [] + monkeypatch.setattr( + desktop.configuration_controller, + "new_sweep", + lambda confirmed: calls.append(("new_sweep", confirmed)) or True, + ) + monkeypatch.setattr( + desktop.configuration_controller, + "new_preparation", + lambda confirmed: calls.append(("new_preparation", confirmed)) or True, + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "get_has_bound_source", + lambda: True, + ) + monkeypatch.setattr( + desktop.configuration_controller, + "import_configuration", + lambda path, confirmed: calls.append(("open", path, confirmed)) or True, + ) + source = tmp_path / "sweep.yaml" + + assert desktop.request_new_sweep(True) + assert desktop.request_new_preparation(True) + assert desktop.request_import_configuration(QUrl.fromLocalFile(str(source)).toString(), True) + assert calls == [ + ("new_sweep", True), + ("new_preparation", True), + ("open", str(source), True), + ] + assert desktop.shutdown() + + +def test_preparation_creation_requires_an_explicit_bound_source_and_routes_to_inspect( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + controller = desktop.configuration_controller + controller.workspace = initialize_workspace(tmp_path / "workspace") + controller.capabilities = {} + assert controller.get_can_create() + failures: list[tuple[str, str]] = [] + navigation: list[tuple[str, str]] = [] + desktop.activityActionFailed.connect(lambda title, message: failures.append((title, message))) + desktop.navigationRequested.connect(lambda page, detail: navigation.append((page, detail))) + + assert not desktop.request_new_preparation() + assert navigation == [("inspect", "preparation-source")] + assert failures == [ + ( + "New ML Preparation", + "Inspect an eligible Dataset or Model Sweep and choose Use for ML Preparation " + "before creating a Preparation configuration.", + ) + ] + assert not controller.new_preparation() + assert navigation == [ + ("inspect", "preparation-source"), + ("inspect", "preparation-source"), + ] + assert len(failures) == 2 + monkeypatch.setattr( - desktop.dataset_config_controller, - "needs_discard_confirmation", + desktop.preparation_workflow_controller, + "get_has_bound_source", lambda: True, ) - - assert not desktop.request_shutdown() - assert confirmations == ["confirm"] - assert not desktop.confirm_shutdown(False) - assert close_requests == [] - assert desktop.confirm_shutdown(True) - assert close_requests == ["close"] - assert desktop.request_shutdown() + assert desktop.request_new_preparation(True) + assert controller.get_document_kind() == "preparation" + assert desktop.shutdown() -def test_qml_shutdown_refuses_an_active_worker_without_closing( +def test_sweep_editor_facade_enforces_document_and_worker_edit_guards( tmp_path: Path, application: QCoreApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) - close_requests: list[str] = [] - desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + selections: list[tuple[str, bool]] = [] + monkeypatch.setattr( + desktop.configuration_controller.sweep_draft, + "set_model_selected", + lambda model, selected: selections.append((model, selected)) or True, + ) + monkeypatch.setattr( + desktop.configuration_controller, + "get_document_kind", + lambda: "dataset", + ) + monkeypatch.setattr(desktop.configuration_controller, "get_can_edit", lambda: True) - with monkeypatch.context() as scoped: - scoped.setattr( - type(desktop.request_coordinator), - "is_busy", - property(lambda _self: True), - ) - assert not desktop.request_shutdown() + desktop.request_sweep_model_selection("pr", True) + assert selections == [] - assert close_requests == [] + monkeypatch.setattr( + desktop.configuration_controller, + "get_document_kind", + lambda: "model_sweep", + ) + desktop.request_sweep_model_selection("pr", True) + assert selections == [("pr", True)] + + monkeypatch.setattr(desktop.configuration_controller, "get_can_edit", lambda: False) + desktop.request_sweep_model_selection("srk", True) + assert selections == [("pr", True)] assert "active worker request" in desktop.get_workspace_error_message() assert desktop.shutdown() -def test_qml_shutdown_cancels_generation_then_closes_after_safe_completion( +def test_preparation_editor_facade_routes_top_level_structured_intent( tmp_path: Path, application: QCoreApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) - confirmations: list[tuple[str, str]] = [] - cancellations: list[str] = [] - close_requests: list[str] = [] - desktop.busyShutdownConfirmationRequested.connect( - lambda mode, message: confirmations.append((mode, message)) - ) - desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) - desktop.request_coordinator._active_session = SimpleNamespace( - owner="execution", - request_type="generate_dataset", - ) - monkeypatch.setattr(desktop.execution_controller, "get_can_cancel", lambda: True) + controller = desktop.configuration_controller + draft = controller.preparation_draft + calls: list[tuple[object, ...]] = [] + + def recorder(name: str) -> object: + def record(*values: object) -> bool: + calls.append((name, *values)) + return True + + return record + + for method in ( + "set_role_selected", + "set_categorical_selected", + "set_category_mode", + "set_explicit_categories", + "set_allow_partial_sweep", + "set_array_outputs_enabled", + "set_include_auxiliary", + "set_matrix_enabled", + "set_baseline_enabled", + "set_array_dtype", + "set_correlation_threshold", + "set_near_constant_spread", + "set_baseline_seed", + "set_ridge_alpha", + "set_histogram_iterations", + "set_array_format_selected", + "set_baseline_model_selected", + ): + monkeypatch.setattr(draft, method, recorder(method)) + monkeypatch.setattr(controller, "get_document_kind", lambda: "preparation") + monkeypatch.setattr(controller, "get_can_edit", lambda: True) + + desktop.request_preparation_role_selection("numeric", "pressure", True) + desktop.request_preparation_categorical_selection("phase", True) + desktop.request_preparation_category_mode("phase", "observed", True) + desktop.request_preparation_explicit_categories("phase", "gas, liquid") + desktop.request_preparation_boolean_field("allow_partial_sweep", True) + desktop.request_preparation_boolean_field("array_outputs", True) + desktop.request_preparation_boolean_field("include_auxiliary", True) + desktop.request_preparation_boolean_field("matrix_diagnostics", True) + desktop.request_preparation_boolean_field("baseline_diagnostics", True) + desktop.request_preparation_text_field("array_dtype", "float64") + desktop.request_preparation_text_field("correlation_threshold", "0.95") + desktop.request_preparation_text_field("near_constant_relative_spread", "1e-9") + desktop.request_preparation_text_field("baseline_random_seed", "42") + desktop.request_preparation_text_field("ridge_alpha", "1.0") + desktop.request_preparation_text_field("histogram_max_iterations", "100") + desktop.request_preparation_array_format_selection("npz", True) + desktop.request_preparation_baseline_model_selection("ridge", True) + desktop.request_preparation_boolean_field("unknown", True) + desktop.request_preparation_text_field("unknown", "ignored") + + assert calls == [ + ("set_role_selected", "numeric", "pressure", True), + ("set_categorical_selected", "phase", True), + ("set_category_mode", "phase", "observed", True), + ("set_explicit_categories", "phase", "gas, liquid"), + ("set_allow_partial_sweep", True), + ("set_array_outputs_enabled", True), + ("set_include_auxiliary", True), + ("set_matrix_enabled", True), + ("set_baseline_enabled", True), + ("set_array_dtype", "float64"), + ("set_correlation_threshold", "0.95"), + ("set_near_constant_spread", "1e-9"), + ("set_baseline_seed", "42"), + ("set_ridge_alpha", "1.0"), + ("set_histogram_iterations", "100"), + ("set_array_format_selected", "npz", True), + ("set_baseline_model_selected", "ridge", True), + ] + assert desktop.shutdown() + + +def test_preparation_editor_facade_enforces_document_and_worker_edit_guards( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + controller = desktop.configuration_controller + selections: list[tuple[str, str, bool]] = [] monkeypatch.setattr( - desktop.execution_controller, - "cancel", - lambda: cancellations.append("cancel") or True, + controller.preparation_draft, + "set_role_selected", + lambda role, value, selected: selections.append((role, value, selected)) or True, ) + monkeypatch.setattr(controller, "get_document_kind", lambda: "dataset") + monkeypatch.setattr(controller, "get_can_edit", lambda: True) - assert not desktop.request_shutdown() - assert confirmations == [ - ( - "cancel_generation", - "Dataset generation is active. Cancel it cooperatively and close Carnopy " - "after the worker and activity record finish safely?", - ) - ] - assert not desktop.confirm_busy_shutdown(False) - assert cancellations == [] + desktop.request_preparation_role_selection("target", "specific_enthalpy", True) + assert selections == [] - assert not desktop.request_shutdown() - assert desktop.confirm_busy_shutdown(True) - assert cancellations == ["cancel"] - desktop.request_coordinator._active_session = None - desktop._complete_busy_shutdown() + monkeypatch.setattr(controller, "get_document_kind", lambda: "preparation") + desktop.request_preparation_role_selection("target", "specific_enthalpy", True) + assert selections == [("target", "specific_enthalpy", True)] - assert close_requests == ["close"] + monkeypatch.setattr(controller, "get_can_edit", lambda: False) + desktop.request_preparation_role_selection("target", "specific_entropy", True) + assert selections == [("target", "specific_enthalpy", True)] + assert "active worker request" in desktop.get_workspace_error_message() + assert desktop.shutdown() -def test_plot_cleanup_failure_aborts_pending_busy_shutdown( +def test_preparation_scenario_facade_routes_only_the_owned_temporary_editor( tmp_path: Path, application: QCoreApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) - confirmations: list[tuple[str, str]] = [] - close_requests: list[str] = [] - desktop.busyShutdownConfirmationRequested.connect( - lambda mode, message: confirmations.append((mode, message)) + controller = desktop.configuration_controller + preparation = controller.preparation_draft + active = ScenarioDraft(parent=preparation) + foreign = ScenarioDraft() + calls: list[tuple[object, ...]] = [] + + def recorder(name: str) -> object: + def record(*values: object) -> bool: + calls.append((name, *values)) + return True + + return record + + for method in ( + "begin_add_scenario", + "begin_edit_scenario", + "commit_scenario", + "cancel_scenario", + "remove_scenario", + "move_scenario", + ): + monkeypatch.setattr(preparation, method, recorder(method)) + for method in ( + "set_name", + "set_seed_text", + "set_field", + "set_remainder", + "apply_kind_change", + "set_partition", + "remove_partition", + "set_categorical_holdout", + "set_range_holdout", + "set_coordinate_holdout", + "remove_holdout", + "set_strata_categorical", + "set_numeric_bins", + "remove_numeric_bins", + "add_transformation", + "remove_transformation", + "move_transformation", + ): + monkeypatch.setattr(active, method, recorder(method)) + monkeypatch.setattr(preparation, "get_active_scenario_draft", lambda: active) + monkeypatch.setattr(controller, "get_document_kind", lambda: "preparation") + monkeypatch.setattr(controller, "get_can_edit", lambda: True) + + assert desktop.request_preparation_add_scenario() + assert desktop.request_preparation_edit_scenario(2) + assert desktop.request_preparation_commit_scenario() + assert desktop.request_preparation_cancel_scenario() + assert desktop.request_preparation_remove_scenario(3) + assert desktop.request_preparation_move_scenario(3, 1) + desktop.request_preparation_scenario_field_change(active, "name", "holdout") + desktop.request_preparation_scenario_field_change(active, "seed", "42") + desktop.request_preparation_scenario_field_change(active, "field", "pressure") + desktop.request_preparation_scenario_field_change(active, "remainder", "train") + desktop.request_preparation_scenario_field_change(active, "unknown", "ignored") + desktop.request_preparation_scenario_kind_change(active, "shuffle", True) + desktop.request_preparation_scenario_partition(active, "test", "0.2") + desktop.request_preparation_scenario_remove_partition(active, "validation") + desktop.request_preparation_scenario_categorical_holdout(active, "test", "gas") + desktop.request_preparation_scenario_range_holdout(active, "test", "1", "2") + desktop.request_preparation_scenario_coordinate_holdout( + active, + "test", + "pressure", + "1", + "2", ) - desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) - desktop.request_coordinator._active_session = SimpleNamespace( - owner="plot", - request_type="render_plot", + desktop.request_preparation_scenario_remove_holdout(active, "test") + desktop.request_preparation_scenario_strata(active, "fluid, phase") + desktop.request_preparation_scenario_numeric_bins(active, "temperature", "250, 300") + desktop.request_preparation_scenario_remove_numeric_bins(active, "temperature") + desktop.request_preparation_scenario_transformation_add( + active, + "pressure", + "log10, standard", ) - monkeypatch.setattr(desktop.session_plot_controller, "get_can_force_stop", lambda: True) - monkeypatch.setattr(desktop.session_plot_controller, "force_stop", lambda: True) + desktop.request_preparation_scenario_transformation_remove(active, 1) + desktop.request_preparation_scenario_transformation_move(active, 1, 0) + + assert calls == [ + ("begin_add_scenario",), + ("begin_edit_scenario", 2), + ("commit_scenario",), + ("cancel_scenario",), + ("remove_scenario", 3), + ("move_scenario", 3, 1), + ("set_name", "holdout"), + ("set_seed_text", "42"), + ("set_field", "pressure"), + ("set_remainder", "train"), + ("apply_kind_change", "shuffle", True), + ("set_partition", "test", "0.2"), + ("remove_partition", "validation"), + ("set_categorical_holdout", "test", "gas"), + ("set_range_holdout", "test", "1", "2"), + ("set_coordinate_holdout", "test", "pressure", "1", "2"), + ("remove_holdout", "test"), + ("set_strata_categorical", "fluid, phase"), + ("set_numeric_bins", "temperature", "250, 300"), + ("remove_numeric_bins", "temperature"), + ("add_transformation", "pressure", "log10, standard"), + ("remove_transformation", 1), + ("move_transformation", 1, 0), + ] - assert not desktop.request_shutdown() - assert confirmations[0][0] == "force_stop_plot" - assert desktop.confirm_busy_shutdown(True) - desktop.request_coordinator._active_session = None - monkeypatch.setattr( - desktop.session_plot_controller, - "get_cleanup_issue", - lambda: "owned staging cleanup failed", - ) - desktop._complete_busy_shutdown() + desktop.request_preparation_scenario_field_change(foreign, "name", "foreign") + assert calls[-1] == ("move_transformation", 1, 0) - assert close_requests == [] - assert "cleanup failed" in desktop.get_workspace_error_message() + monkeypatch.setattr(controller, "get_document_kind", lambda: "dataset") + assert not desktop.request_preparation_add_scenario() + desktop.request_preparation_scenario_field_change(active, "name", "wrong-kind") + assert calls[-1] == ("move_transformation", 1, 0) + + monkeypatch.setattr(controller, "get_document_kind", lambda: "preparation") + monkeypatch.setattr(controller, "get_can_edit", lambda: False) + assert not desktop.request_preparation_add_scenario() + desktop.request_preparation_scenario_field_change(active, "name", "busy") + assert calls[-1] == ("move_transformation", 1, 0) + assert "active worker request" in desktop.get_workspace_error_message() + foreign.deleteLater() assert desktop.shutdown() -def test_qml_shutdown_explicitly_cancels_transient_plot_edits_before_close( +def test_workflow_result_handoff_inspects_exact_finalized_outputs_without_rebinding( tmp_path: Path, application: QCoreApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) - confirmations: list[str] = [] - close_requests: list[str] = [] - cancellations: list[str] = [] - desktop.transientEditShutdownConfirmationRequested.connect(confirmations.append) - desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) - monkeypatch.setattr(desktop, "get_has_active_plot_edit", lambda: False) - monkeypatch.setattr(desktop, "get_has_session_plot_edit", lambda: True) + sweep_output = tmp_path / "workspace" / "outputs" / "sweep-run" + preparation_output = tmp_path / "workspace" / "outputs" / "preparation-run" + inspected: list[str] = [] + navigation: list[tuple[str, str]] = [] + failures: list[tuple[str, str]] = [] + binding_calls: list[str] = [] + desktop.navigationRequested.connect(lambda page, detail: navigation.append((page, detail))) + desktop.activityActionFailed.connect(lambda title, message: failures.append((title, message))) monkeypatch.setattr( - desktop.session_plot_controller, - "cancel_edit", - lambda: cancellations.append("session") or True, + desktop.sweep_workflow_controller, + "get_result_output_directory", + lambda: str(sweep_output), + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "get_result_output_directory", + lambda: str(preparation_output), + ) + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "bind_inspected_source", + lambda: binding_calls.append("bind") or True, + ) + monkeypatch.setattr( + desktop.inspection_controller, + "inspect_source", + lambda value: inspected.append(str(value)) or True, + ) + refreshed_sources: list[str] = [] + monkeypatch.setattr( + desktop.inspection_controller, + "refresh_sources", + lambda: refreshed_sources.append("refresh"), ) - assert not desktop.request_shutdown() - assert confirmations == [ - "A session plot edit is still open. Cancel the edit and close Carnopy?" + assert desktop.request_workflow_inspect_result("sweep") + assert desktop.request_workflow_inspect_result("preparation") + assert inspected == [str(sweep_output), str(preparation_output)] + assert navigation == [("inspect", ""), ("inspect", "")] + assert binding_calls == [] + assert failures == [] + desktop.preparation_workflow_controller.output_finalized.emit(preparation_output) + assert refreshed_sources == ["refresh"] + + assert not desktop.request_workflow_inspect_result("unknown") + assert failures == [ + ( + "Inspect Result", + "Complete this workflow successfully before inspecting its finalized output.", + ) ] - assert not desktop.confirm_transient_edit_shutdown(False) - assert cancellations == [] - assert close_requests == [] - assert desktop.confirm_transient_edit_shutdown(True) - assert cancellations == ["session"] - assert close_requests == ["close"] + assert desktop.shutdown() -def test_configuration_attention_facade_accepts_only_stable_sections( +def test_preparation_source_facade_requires_confirmation_for_a_current_plan( tmp_path: Path, application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) - attention: list[tuple[str, str, int]] = [] - desktop.attentionRequested.connect( - lambda section, field, row: attention.append((section, field, row)) + controller = desktop.preparation_workflow_controller + calls: list[str] = [] + confirmations: list[str] = [] + desktop.preparationSourceClearConfirmationRequested.connect( + lambda: confirmations.append("clear") + ) + monkeypatch.setattr( + controller, + "bind_inspected_source", + lambda: calls.append("bind") or True, + ) + monkeypatch.setattr(controller, "get_has_bound_source", lambda: True) + monkeypatch.setattr(controller, "get_plan_current", lambda: True) + monkeypatch.setattr( + controller, + "clear_bound_source", + lambda: calls.append("clear") or True, ) - assert desktop.request_configuration_attention("dataset", "dataset.properties", 2) - assert desktop.request_configuration_attention("visualization", "plot.name", -1) - assert not desktop.request_configuration_attention("workspace", "dataset.mode", -1) - assert not desktop.request_configuration_attention("dataset", "plot.name", -1) - assert attention == [ - ("dataset", "dataset.properties", 2), - ("visualization", "plot.name", -1), - ] + assert desktop.request_bind_inspected_preparation_source() + assert not desktop.request_clear_preparation_source() + assert confirmations == ["clear"] + assert calls == ["bind"] + + assert desktop.request_clear_preparation_source(confirmed=True) + assert calls == ["bind", "clear"] + + monkeypatch.setattr(controller, "get_plan_current", lambda: False) + assert desktop.request_clear_preparation_source() + assert calls == ["bind", "clear", "clear"] + assert confirmations == ["clear"] assert desktop.shutdown() -def test_execution_facade_routes_qml_intent_to_the_authoritative_controller( +def test_bound_preparation_profile_is_the_only_profile_applied_to_the_draft( tmp_path: Path, application: QCoreApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) - calls: list[str] = [] - monkeypatch.setattr( - desktop.execution_controller, - "validate", - lambda: calls.append("validate") or True, - ) + source = tmp_path / "workspace" / "outputs" / "dataset-run" + profile = {"source_kind": "dataset_run", "inspection_revision": "a" * 64} + applied: list[object] = [] monkeypatch.setattr( - desktop.execution_controller, - "generate", - lambda: calls.append("generate") or True, + desktop.preparation_workflow_controller, + "bound_source_snapshot", + lambda: (source, "a" * 64, {"source_path": str(source)}, profile), ) monkeypatch.setattr( - desktop.execution_controller, - "cancel", - lambda: calls.append("cancel") or True, + desktop.configuration_controller.preparation_draft, + "apply_source_profile", + applied.append, ) + + desktop._preparation_source_binding_changed() + assert applied == [profile] + monkeypatch.setattr( - desktop.execution_controller, - "force_stop", - lambda: calls.append("force_stop") or True, + desktop.preparation_workflow_controller, + "bound_source_snapshot", + lambda: None, ) - - assert desktop.request_execution_validation() - assert desktop.request_dataset_generation() - assert desktop.request_execution_cancel() - assert desktop.request_execution_force_stop() - assert calls == ["validate", "generate", "cancel", "force_stop"] + desktop._preparation_source_binding_changed() + assert applied == [profile, None] assert desktop.shutdown() +@pytest.mark.parametrize( + "controller_name", + [ + "execution_controller", + "sweep_workflow_controller", + "preparation_workflow_controller", + ], +) def test_execution_record_changes_refresh_the_shared_activity_projection( tmp_path: Path, application: QCoreApplication, + controller_name: str, ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) @@ -415,7 +1661,7 @@ def test_execution_record_changes_refresh_the_shared_activity_projection( config_sha256="a" * 64, ) - desktop.execution_controller.activity_record_changed.emit() + getattr(desktop, controller_name).activity_record_changed.emit() assert desktop.activity_controller.records_model.get_count() == 1 assert desktop.activity_controller.records_model.rows()[0]["state"] == "interrupted" @@ -523,7 +1769,7 @@ def test_save_as_facade_converts_qml_file_urls_at_the_composition_boundary( destination = tmp_path / "workspace" / "configs" / "dataset.yaml" observed: list[str] = [] monkeypatch.setattr( - desktop.dataset_config_controller, + desktop.configuration_controller, "save_path_selected", lambda path: observed.append(path) or True, ) @@ -542,7 +1788,7 @@ def test_desktop_workspace_facade_validates_create_name_and_binds_configuration_ desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) activated: list[object] = [] monkeypatch.setattr( - desktop.dataset_config_controller, + desktop.configuration_controller, "set_workspace", activated.append, ) @@ -573,7 +1819,7 @@ def test_desktop_workspace_facade_requires_initialization_confirmation( ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) - monkeypatch.setattr(desktop.dataset_config_controller, "set_workspace", lambda _value: None) + monkeypatch.setattr(desktop.configuration_controller, "set_workspace", lambda _value: None) target = tmp_path / "existing" target.mkdir() @@ -596,9 +1842,9 @@ def test_desktop_workspace_facade_rechecks_dirty_confirmation_before_commit( ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) - monkeypatch.setattr(desktop.dataset_config_controller, "set_workspace", lambda _value: None) + monkeypatch.setattr(desktop.configuration_controller, "set_workspace", lambda _value: None) monkeypatch.setattr( - desktop.dataset_config_controller, + desktop.configuration_controller, "needs_discard_confirmation", lambda: True, ) @@ -677,7 +1923,10 @@ def test_active_plot_edit_blocks_all_composition_lifecycle_paths( ) for name in ( "new_dataset", + "new_sweep", + "new_preparation", "import_dataset", + "import_configuration", "request_save", "request_save_as", "request_validation", @@ -686,13 +1935,16 @@ def test_active_plot_edit_blocks_all_composition_lifecycle_paths( "apply_coordinate_change", ): monkeypatch.setattr( - desktop.dataset_config_controller, + desktop.configuration_controller, name, lambda *_args, operation=name: calls.append(operation) or True, ) assert not desktop.request_new_dataset("property_table") + assert not desktop.request_new_sweep() + assert not desktop.request_new_preparation() assert not desktop.request_import_dataset("input.yaml") + assert not desktop.request_import_configuration("input.yaml") assert not desktop.request_save() assert not desktop.request_save_as() assert not desktop.request_validate_configuration() @@ -704,7 +1956,7 @@ def test_active_plot_edit_blocks_all_composition_lifecycle_paths( assert not desktop.request_visualization_edit_plot(0) assert not desktop.request_visualization_remove_plot(0) assert not desktop.request_visualization_move_plot(0, 1) - assert not desktop.dataset_config_controller.clear_document(discard_confirmed=True) + assert not desktop.configuration_controller.clear_document(discard_confirmed=True) assert not desktop.shutdown() assert calls == [] @@ -712,6 +1964,76 @@ def test_active_plot_edit_blocks_all_composition_lifecycle_paths( assert all(item == ("visualization", "visualization.plots", -1) for item in attention) +def test_active_preparation_edit_blocks_workspace_and_configuration_lifecycle( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + preparation = desktop.configuration_controller.preparation_draft + calls: list[str] = [] + attention: list[tuple[str, str, int]] = [] + desktop.attentionRequested.connect( + lambda section, field, row: attention.append((section, field, row)) + ) + monkeypatch.setattr(preparation, "get_has_active_scenario_edit", lambda: True) + monkeypatch.setattr( + preparation, + "get_first_invalid_field", + lambda: "preparation.scenario.active.name", + ) + monkeypatch.setattr(preparation, "get_first_invalid_row", lambda: -1) + for name in ( + "new_dataset", + "new_sweep", + "new_preparation", + "import_dataset", + "import_configuration", + "request_save", + "request_save_as", + "request_validation", + "reload_source", + ): + monkeypatch.setattr( + desktop.configuration_controller, + name, + lambda *_args, operation=name: calls.append(operation) or True, + ) + preflight_calls: list[str] = [] + monkeypatch.setattr( + desktop.workspace_controller, + "prepare_create", + lambda _path: preflight_calls.append("workspace") or True, + ) + + assert desktop.get_has_active_preparation_edit() + assert desktop.get_has_any_transient_edit() + assert not desktop.get_can_change_workspace() + assert not desktop.prepare_create_workspace_path(str(tmp_path / "workspace")) + assert not desktop.request_new_dataset("property_table") + assert not desktop.request_new_sweep() + assert not desktop.request_new_preparation() + assert not desktop.request_import_dataset("input.yaml") + assert not desktop.request_import_configuration("input.yaml") + assert not desktop.request_save() + assert not desktop.request_save_as() + assert not desktop.request_validate_configuration() + assert not desktop.request_reload_source() + assert not desktop.request_close_configuration() + assert not desktop.shutdown() + + assert calls == [] + assert preflight_calls == [] + assert attention + assert all( + item == ("preparation", "preparation.scenario.active.name", -1) for item in attention + ) + assert "Preparation scenario" in desktop.get_workspace_error_message() + monkeypatch.setattr(preparation, "get_has_active_scenario_edit", lambda: False) + assert desktop.shutdown() + + def test_session_plot_edit_guards_replacement_but_not_configuration_save( tmp_path: Path, application: QCoreApplication, @@ -737,7 +2059,7 @@ def test_session_plot_edit_guards_replacement_but_not_configuration_save( ) save_calls: list[str] = [] monkeypatch.setattr( - desktop.dataset_config_controller, + desktop.configuration_controller, "request_save", lambda *_args: save_calls.append("save") or True, ) @@ -759,9 +2081,16 @@ def test_session_plot_edit_guards_replacement_but_not_configuration_save( def test_visualization_facade_accepts_only_the_owned_active_plot_and_mappings( tmp_path: Path, application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + monkeypatch.setattr( + desktop.configuration_controller, + "get_document_kind", + lambda: "dataset", + ) + monkeypatch.setattr(desktop.configuration_controller, "get_can_edit", lambda: True) draft = desktop.visualization_draft draft.apply_capabilities( { @@ -817,6 +2146,18 @@ def test_visualization_facade_accepts_only_the_owned_active_plot_and_mappings( assert active.get_name() == "density" assert active.filters.raw_rows() == (("temperature", "300"),) + + monkeypatch.setattr(draft, "get_active_plot_draft", lambda: None) + monkeypatch.setattr( + desktop.session_plot_controller, + "get_active_plot_draft", + lambda: active, + ) + monkeypatch.setattr(desktop.session_plot_controller, "get_is_rendering", lambda: True) + desktop.request_plot_field_change(active, "name", "rendering-mutation") + assert active.get_name() == "density" + assert "active plot worker" in desktop.get_workspace_error_message() + assert desktop.request_visualization_cancel_plot() assert desktop.shutdown() @@ -828,6 +2169,12 @@ def test_dataset_replacement_decisions_are_owned_by_desktop_facade( ) -> None: del application desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + monkeypatch.setattr( + desktop.configuration_controller, + "get_document_kind", + lambda: "dataset", + ) + monkeypatch.setattr(desktop.configuration_controller, "get_can_edit", lambda: True) desktop.dataset_draft.mode_choices.replace( DraftItem(value=value, display=value, canonical=value) for value in ("property_table", "saturation_table") @@ -840,12 +2187,12 @@ def test_dataset_replacement_decisions_are_owned_by_desktop_facade( monkeypatch.setattr(desktop.dataset_draft, "get_coordinate_name", lambda: "temperature") applied: list[tuple[str, str]] = [] monkeypatch.setattr( - desktop.dataset_config_controller, + desktop.configuration_controller, "apply_mode_change", lambda value: applied.append(("mode", value)) or True, ) monkeypatch.setattr( - desktop.dataset_config_controller, + desktop.configuration_controller, "apply_coordinate_change", lambda value: applied.append(("coordinate", value)) or True, ) diff --git a/tests/test_app_execution_controller.py b/tests/test_app_execution_controller.py index bbe2d77..d142736 100644 --- a/tests/test_app_execution_controller.py +++ b/tests/test_app_execution_controller.py @@ -17,8 +17,8 @@ from PySide6.QtWidgets import QApplication from carnopy.app.client import TransportOutcome, WorkerClient -from carnopy.app.config_controller import DatasetConfigController -from carnopy.app.config_document import ConfigDocumentError, SavedConfigSnapshot +from carnopy.app.config_controller import ConfigurationController +from carnopy.app.config_document import ConfigDocumentError, DocumentType, SavedConfigSnapshot from carnopy.app.execution_controller import DatasetExecutionController from carnopy.app.jobs import JobStore from carnopy.app.protocol import EventType, RequestType, WorkerEvent @@ -123,7 +123,12 @@ def __init__(self, snapshot: SavedConfigSnapshot) -> None: workspace_owned=True, ) - def execution_snapshot(self) -> SavedConfigSnapshot: + def execution_snapshot( + self, + *, + expected_document_type: DocumentType = "dataset", + ) -> SavedConfigSnapshot: + assert expected_document_type == "dataset" if self.snapshot_issue is not None: raise ConfigDocumentError(self.snapshot_issue) return self.snapshot @@ -153,16 +158,17 @@ def controller_for( content = b"schema_version: 2\n" config_path.write_bytes(content) snapshot = SavedConfigSnapshot( - config_path.resolve(), - content, - hashlib.sha256(content).hexdigest(), + path=config_path.resolve(), + yaml_bytes=content, + sha256=hashlib.sha256(content).hexdigest(), + document_type="dataset", ) config = StubConfigController(snapshot) transport = StubTransport() coordinator = DesktopRequestCoordinator(cast(WorkerClient, transport)) controller = DatasetExecutionController( coordinator, - cast(DatasetConfigController, config), + cast(ConfigurationController, config), ) controller.set_workspace(workspace) return controller, config, coordinator, transport, workspace diff --git a/tests/test_app_inspection.py b/tests/test_app_inspection.py index 5b2b7f4..8d8dd61 100644 --- a/tests/test_app_inspection.py +++ b/tests/test_app_inspection.py @@ -8,6 +8,7 @@ import pytest from carnopy.app.source_inspection import inspect_for_app, resolve_table +from carnopy.app.table_preview import preview_table from carnopy.inspection import inspect_source from carnopy.visualization.models import VisualizationError @@ -16,6 +17,94 @@ def _sha(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() +def _write_preparation_dataset_run( + root: Path, + *, + model: str = "heos", + run_status: str = "completed", +) -> Path: + root.mkdir(parents=True) + dataset = root / "dataset.parquet" + pd.DataFrame( + { + "run_id": [f"run-{model}", f"run-{model}"], + "case_id": [0, 1], + "mode": ["property_table", "property_table"], + "fluid": ["Propane", "n-Butane"], + "backend": ["coolprop", "coolprop"], + "backend_model": [model, model], + "backend_version": ["test", "test"], + "phase": ["gas", "liquid"], + "valid": [True, True], + "temperature_K": [300.0, 310.0], + "pressure_Pa": [100000.0, 200000.0], + "mass_density_kg_m3": [1.8, 570.0], + "specific_enthalpy_J_kg": [420000.0, 240000.0], + "critical_temperature_K": [369.89, 425.12], + "critical_pressure_Pa": [4251200.0, 3796000.0], + "molar_mass_kg_mol": [0.04409562, 0.0581222], + } + ).to_parquet(dataset, index=False) + metadata = { + "run_id": f"run-{model}", + "run_status": run_status, + "backend": "coolprop", + "backend_model": model, + "reference_state_policy": "coolprop_DEF", + "reference_state_backend_model": model, + "reference_state_targets": [f"{model}::Propane", f"{model}::n-Butane"], + "canonical_units": { + "temperature_K": "K", + "pressure_Pa": "Pa", + "mass_density_kg_m3": "kg/m^3", + "specific_enthalpy_J_kg": "J/kg", + "critical_temperature_K": "K", + "critical_pressure_Pa": "Pa", + "molar_mass_kg_mol": "kg/mol", + }, + "artifact_hashes": {"dataset.parquet": _sha(dataset)}, + } + (root / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + return root + + +def _write_preparation_sweep( + root: Path, + *, + included_models: tuple[str, ...], + status: str, +) -> Path: + root.mkdir() + (root / "sweep.normalized.json").write_text("{}\n", encoding="utf-8") + (root / "report.json").write_text("{}\n", encoding="utf-8") + child_runs: list[dict[str, str]] = [] + for model in included_models: + child = _write_preparation_dataset_run( + root / "models" / model / f"run-{model}", + model=model, + ) + child_runs.append( + { + "backend_model": model, + "output_directory": str(child), + "run_id": f"run-{model}", + } + ) + metadata = { + "sweep_id": "sweep-id", + "sweep_run_id": "sweep-run-id", + "sweep_status": status, + "backend": "coolprop", + "mode": "property_table", + "models": ["heos", "pr"], + "reference_model": "heos", + "child_runs": child_runs, + "artifact_hashes": {}, + } + (root / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + return root + + def test_dataset_app_inspection_returns_stable_descriptor_and_revision(tmp_path: Path) -> None: dataset = tmp_path / "dataset.parquet" pd.DataFrame( @@ -38,6 +127,7 @@ def test_dataset_app_inspection_returns_stable_descriptor_and_revision(tmp_path: assert inspected.source_kind == "dataset" assert [item.table_id for item in inspected.tables] == ["dataset"] + assert inspected.preparation_profile is None assert resolved.path == dataset dataset.write_bytes(dataset.read_bytes() + b"changed") @@ -45,6 +135,136 @@ def test_dataset_app_inspection_returns_stable_descriptor_and_revision(tmp_path: resolve_table(dataset, "dataset", inspected.revision) +def test_dataset_run_projects_authoritative_preparation_profile(tmp_path: Path) -> None: + run = _write_preparation_dataset_run(tmp_path / "dataset-run") + + inspected = inspect_for_app(run) + + profile = inspected.preparation_profile + assert inspected.preparation_eligible + assert inspected.preparation_source_descriptor is not None + assert profile is not None + assert inspected.public_payload()["preparation_profile"] == profile + assert profile["profile_schema_version"] == 1 + assert profile["source_path"] == str(run) + assert profile["source_kind"] == "dataset_run" + assert profile["inspection_revision"] == inspected.revision + assert profile["source_identity"]["run_id"] == "run-heos" + assert profile["completion"] == { + "status": "completed", + "partial": False, + "included_child_models": [], + "missing_child_models": [], + } + assert profile["available_models"] == ["heos"] + assert profile["declared_models"] == [] + assert profile["reference_model"] == "heos" + numeric_names = [item["name"] for item in profile["numeric_candidates"]] + assert numeric_names == [ + "temperature", + "pressure", + "specific_enthalpy", + "mass_density", + "molar_mass", + "critical_temperature", + "critical_pressure", + ] + assert profile["target_candidates"] == profile["numeric_candidates"] + assert [item["name"] for item in profile["categorical_candidates"]] == [ + "phase", + "fluid", + ] + assert profile["observed_category_values"] == { + "phase": ["gas", "liquid"], + "fluid": ["Propane", "n-Butane"], + "backend_model": ["heos"], + } + assert all(item["status"] == "ready" for item in profile["derived_features"]) + assert profile["model_holdout"] == { + "available": False, + "reason": "Model holdout scenarios require a model-sweep source.", + } + assert profile["reference_context"]["compatible"] is True + assert profile["reference_context"]["compatible_context"] == { + "reference_state_policy": "coolprop_DEF", + "backend": "coolprop", + "backend_model": "heos", + } + + +def test_preparation_profile_reports_partial_and_unavailable_derived_features( + tmp_path: Path, +) -> None: + run = _write_preparation_dataset_run(tmp_path / "dataset-run") + dataset = run / "dataset.parquet" + frame = pd.read_parquet(dataset) + frame.loc[0, "mass_density_kg_m3"] = None + frame = frame.drop(columns="critical_pressure_Pa") + frame.to_parquet(dataset, index=False) + metadata_path = run / "metadata.json" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + metadata["artifact_hashes"]["dataset.parquet"] = _sha(dataset) + metadata_path.write_text(json.dumps(metadata), encoding="utf-8") + + profile = inspect_for_app(run).preparation_profile + + assert profile is not None + derived = {item["name"]: item for item in profile["derived_features"]} + assert derived["specific_volume"]["status"] == "partial" + assert derived["specific_volume"]["ready_row_count"] == 1 + assert derived["specific_volume"]["missing_dependencies"] == ["mass_density"] + assert "missing_derived_dependency" in derived["specific_volume"]["reason_codes"] + assert derived["reduced_pressure"]["status"] == "unavailable" + assert derived["reduced_pressure"]["missing_dependencies"] == ["critical_pressure"] + assert derived["reduced_pressure"]["reason"] + + +def test_sweep_preparation_profile_reports_partial_and_reference_context( + tmp_path: Path, +) -> None: + partial = _write_preparation_sweep( + tmp_path / "partial-sweep", + included_models=("heos",), + status="incomplete", + ) + + partial_inspection = inspect_for_app(partial) + + partial_profile = partial_inspection.preparation_profile + assert partial_inspection.preparation_eligible + assert partial_profile is not None + assert partial_profile["source_kind"] == "model_sweep" + assert partial_profile["completion"] == { + "status": "incomplete", + "partial": True, + "included_child_models": ["heos"], + "missing_child_models": ["pr"], + } + assert partial_profile["available_models"] == ["heos"] + assert partial_profile["declared_models"] == ["heos", "pr"] + assert partial_profile["reference_model"] == "heos" + assert partial_profile["model_holdout"] == { + "available": False, + "reason": ("Model holdout scenarios require at least two available sweep child models."), + } + assert partial_profile["reference_context"]["compatible"] is True + + complete = _write_preparation_sweep( + tmp_path / "complete-sweep", + included_models=("heos", "pr"), + status="completed", + ) + + complete_profile = inspect_for_app(complete).preparation_profile + + assert complete_profile is not None + assert complete_profile["completion"]["partial"] is False + assert complete_profile["available_models"] == ["heos", "pr"] + assert complete_profile["model_holdout"] == {"available": True, "reason": ""} + assert complete_profile["reference_context"]["compatible"] is False + assert complete_profile["reference_context"]["reason_code"] == "incompatible_reference_context" + + def test_dataset_run_without_generator_metadata_remains_inspectable(tmp_path: Path) -> None: run = tmp_path / "legacy-run" run.mkdir() @@ -144,7 +364,7 @@ def test_shared_preparation_inspection_rejects_recorded_hash_mismatch(tmp_path: inspect_source(root) -def test_preparation_descriptors_cover_main_and_scenario_tables(tmp_path: Path) -> None: +def test_preparation_descriptors_cover_quality_flags_and_scenario_tables(tmp_path: Path) -> None: root = tmp_path / "preparation" data = root / "data" scenario = data / "scenarios" / "shuffle" @@ -156,6 +376,13 @@ def test_preparation_descriptors_cover_main_and_scenario_tables(tmp_path: Path) "data/provenance.parquet": pd.DataFrame({"prepared_row_id": [0, 1]}), "data/diagnostics.parquet": pd.DataFrame({"prepared_row_id": [0, 1]}), "data/exclusions.parquet": pd.DataFrame({"primary_reason": []}), + "data/quality_flags.parquet": pd.DataFrame( + { + "prepared_row_id": [0, 1, 1], + "flag_code": ["candidate_a", "candidate_b", "candidate_c"], + "severity": ["advisory", "warning", "advisory"], + } + ), "data/scenarios/shuffle/train.parquet": pd.DataFrame({"prepared_row_id": [0], "x": [1.0]}), "data/scenarios/shuffle/test.parquet": pd.DataFrame({"prepared_row_id": [1], "x": [2.0]}), } @@ -163,7 +390,25 @@ def test_preparation_descriptors_cover_main_and_scenario_tables(tmp_path: Path) destination = root / relative destination.parent.mkdir(parents=True, exist_ok=True) frame.to_parquet(destination, index=False) + scenario_artifact = scenario / "scenario.json" + scenario_artifact.write_text( + json.dumps( + { + "name": "shuffle", + "kind": "shuffle", + "partition_counts": {"train": 1, "test": 1}, + "state_leakage": { + "identity_column": "source_state_hash", + "duplicate_state_group_count": 0, + "cross_partition_group_count": 0, + }, + } + ), + encoding="utf-8", + ) hashes = {relative: _sha(root / relative) for relative in paths} + scenario_relative = "data/scenarios/shuffle/scenario.json" + hashes[scenario_relative] = _sha(scenario_artifact) manifest = { "status": "completed", "eligible_row_count": 2, @@ -175,17 +420,21 @@ def test_preparation_descriptors_cover_main_and_scenario_tables(tmp_path: Path) "exclusions": "data/exclusions.parquet", }, "artifact_hashes": hashes, + "quality_artifacts": {"flags": "data/quality_flags.parquet"}, "array_exports": { "enabled": True, "exports": [{"path": "data/arrays/features.float32.npy", "format": "npy"}], }, "scenarios": { + "status": "completed", "scenario_count": 1, "partition_count": 2, "scenarios": [ { "name": "shuffle", + "kind": "shuffle", "partition_counts": {"train": 1, "test": 1}, + "transformations": [], "partition_artifacts": [ "data/scenarios/shuffle/train.parquet", "data/scenarios/shuffle/test.parquet", @@ -197,6 +446,15 @@ def test_preparation_descriptors_cover_main_and_scenario_tables(tmp_path: Path) "data/scenarios/shuffle/test.parquet", ) }, + "scenario_artifact": scenario_relative, + "artifact_hashes": { + key: hashes[key] + for key in ( + "data/scenarios/shuffle/train.parquet", + "data/scenarios/shuffle/test.parquet", + scenario_relative, + ) + }, } ], }, @@ -210,10 +468,59 @@ def test_preparation_descriptors_cover_main_and_scenario_tables(tmp_path: Path) "provenance", "diagnostics", "exclusions", + "quality_flags", "scenario.shuffle.train", "scenario.shuffle.test", ] assert inspected.arrays == ({"path": "data/arrays/features.float32.npy", "format": "npy"},) + assert inspected.preparation_audit == { + "audit_schema_version": 1, + "scenario_details": [ + { + "name": "shuffle", + "state_leakage": { + "identity_column": "source_state_hash", + "duplicate_state_group_count": 0, + "cross_partition_group_count": 0, + }, + } + ], + } + assert inspected.public_payload()["preparation_audit"] == inspected.preparation_audit + + quality_flags = resolve_table(root, "quality_flags", inspected.revision) + assert quality_flags.path == data / "quality_flags.parquet" + assert quality_flags.sha256 == hashes["data/quality_flags.parquet"] + preview = preview_table(quality_flags, offset=1, limit=1) + assert preview["table_id"] == "quality_flags" + assert preview["total_row_count"] == 3 + assert preview["block_offset"] == 1 + assert preview["block_count"] == 1 + assert preview["rows"] == [[1, "candidate_b", "warning"]] + with pytest.raises(ValueError, match="between 1 and 500"): + preview_table(quality_flags, offset=0, limit=501) + + scenario_bytes = scenario_artifact.read_bytes() + replacement = scenario_artifact.with_name("scenario-replacement.json") + replacement.write_bytes(scenario_bytes) + replacement.replace(scenario_artifact) + same_content = inspect_for_app(root) + assert same_content.revision != inspected.revision + assert same_content.preparation_audit == inspected.preparation_audit + + scenario_artifact.write_text("{}", encoding="utf-8") + with pytest.raises(VisualizationError, match="scenario shuffle audit artifact hash mismatch"): + inspect_for_app(root) + scenario_artifact.write_bytes(scenario_bytes) + + quality_flags.path.write_bytes(b"tampered") + refreshed = inspect_for_app(root) + assert refreshed.revision != inspected.revision + assert "quality_flags" not in {table.table_id for table in refreshed.tables} + assert refreshed.summary["quality"]["summary"]["status"] == "corrupt_or_missing" + assert any("hash mismatch" in issue for issue in refreshed.summary["quality"]["errors"]) + with pytest.raises(VisualizationError, match="changed"): + resolve_table(root, "quality_flags", inspected.revision) def test_sweep_descriptors_cover_comparisons_and_child_datasets(tmp_path: Path) -> None: diff --git a/tests/test_app_inspection_controller.py b/tests/test_app_inspection_controller.py index 63fd38c..04a8bcb 100644 --- a/tests/test_app_inspection_controller.py +++ b/tests/test_app_inspection_controller.py @@ -1,17 +1,20 @@ from __future__ import annotations +import hashlib +import json import os import subprocess import sys from pathlib import Path from typing import cast +from uuid import UUID, uuid4 import pytest os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") pytest.importorskip("PySide6") -from PySide6.QtCore import QEventLoop, QTimer +from PySide6.QtCore import QEventLoop, QObject, QTimer, Signal from PySide6.QtWidgets import QApplication from carnopy.app.client import WorkerClient @@ -20,17 +23,81 @@ InspectionController, discover_workspace_sources, ) -from carnopy.app.request_coordinator import DesktopRequestCoordinator +from carnopy.app.request_coordinator import DesktopRequestCoordinator, RequestSession from carnopy.app.workspace import initialize_workspace REVISION = "a" * 64 +class StubSession(QObject): + completed = Signal(object) + + def __init__(self) -> None: + super().__init__() + self.request_id = uuid4() + + +class StubOutcome: + def __init__( + self, + request_id: UUID, + *, + result: dict[str, object] | None = None, + failure: dict[str, object] | None = None, + ) -> None: + self.request_id = request_id + self.result_payload = result + self.failure_payload = failure + + +class StubCoordinator(QObject): + busy_changed = Signal(bool) + + def __init__(self) -> None: + super().__init__() + self.is_busy = False + self.session: StubSession | None = None + + def start_request( + self, + _owner: str, + _request_type: str, + _payload: dict[str, object], + ) -> RequestSession: + if self.is_busy: + raise RuntimeError("request already active") + self.session = StubSession() + self.is_busy = True + self.busy_changed.emit(True) + return cast(RequestSession, self.session) + + def succeed(self, payload: dict[str, object]) -> None: + session = self.session + assert session is not None + session.completed.emit(StubOutcome(session.request_id, result=payload)) + self.session = None + self.is_busy = False + self.busy_changed.emit(False) + + def fail(self, payload: dict[str, object]) -> None: + session = self.session + assert session is not None + session.completed.emit(StubOutcome(session.request_id, failure=payload)) + self.session = None + self.is_busy = False + self.busy_changed.emit(False) + + def controller_for() -> tuple[InspectionController, DesktopRequestCoordinator]: coordinator = DesktopRequestCoordinator(WorkerClient()) return InspectionController(coordinator), coordinator +def stub_controller_for() -> tuple[InspectionController, StubCoordinator]: + coordinator = StubCoordinator() + return InspectionController(cast(DesktopRequestCoordinator, coordinator)), coordinator + + def prepare_payload( controller: InspectionController, source: Path, @@ -49,6 +116,143 @@ def prepare_payload( ) +def preparation_profile( + source: Path, + *, + revision: str = REVISION, + source_kind: str = "dataset_run", +) -> dict[str, object]: + field = { + "name": "temperature", + "column": "temperature_K", + "unit": "K", + "source": "coordinate", + "reference_dependent": False, + } + return { + "profile_schema_version": 1, + "source_path": str(source.resolve()), + "source_kind": source_kind, + "inspection_revision": revision, + "source_identity": {"source_kind": source_kind}, + "completion": { + "status": "completed", + "partial": False, + "included_child_models": [], + "missing_child_models": [], + }, + "available_models": ["heos"], + "declared_models": [], + "reference_model": "heos", + "numeric_candidates": [field], + "target_candidates": [dict(field)], + "categorical_candidates": [ + { + "name": "fluid", + "column": "fluid", + "unit": None, + "source": "categorical", + "reference_dependent": False, + } + ], + "auxiliary_candidates": [ + { + "name": "run_id", + "column": "run_id", + "unit": None, + "source": "auxiliary", + "reference_dependent": False, + } + ], + "observed_category_values": {"fluid": ["Propane"]}, + "derived_features": [ + { + "name": "specific_volume", + "status": "ready", + "available": True, + "ready_row_count": 2, + "source_row_count": 2, + "reason": "", + "reason_codes": [], + "missing_dependencies": [], + "dependencies": ["mass_density"], + "unit": "m^3/kg", + } + ], + "model_holdout": { + "available": False, + "reason": "Model holdout scenarios require a model-sweep source.", + }, + "reference_context": { + "compatible": True, + "compatible_context": { + "reference_state_policy": "coolprop_DEF", + "backend": "coolprop", + "backend_model": "heos", + }, + "contexts": [ + { + "artifact": "dataset.parquet", + "run_id": "run", + "backend": "coolprop", + "backend_model": "heos", + "reference_state_policy": "coolprop_DEF", + "reference_state_backend_model": "heos", + "reference_state_targets": ["HEOS::Propane"], + } + ], + "reason_code": "", + "reason": "", + }, + } + + +def finalized_preparation_audit_payload(source: Path) -> dict[str, object]: + return { + "source_kind": "preparation", + "summary": { + "source": str(source.resolve()), + "status": "completed", + "row_counts": {"source": 2, "eligible": 2, "excluded": 0}, + "scenarios": { + "status": "completed", + "scenario_count": 1, + "partition_count": 2, + "scenarios": [ + { + "name": "shuffle", + "kind": "shuffle", + "partition_counts": {"test": 1, "train": 1}, + "transformations": [], + } + ], + }, + "quality": { + "errors": [], + "summary": { + "status": "completed", + "row_counts": {"eligible": 2, "excluded": 0}, + "matrix_diagnostics": {"status": "not_requested", "fits": []}, + "baseline_diagnostics": {"status": "not_requested", "fits": []}, + }, + }, + }, + "preparation_audit": { + "audit_schema_version": 1, + "scenario_details": [ + { + "name": "shuffle", + "state_leakage": { + "identity_column": "source_state_hash", + "duplicate_state_group_count": 0, + "cross_partition_group_count": 0, + }, + } + ], + }, + } + + def test_workspace_sources_are_direct_bounded_and_newest_first(tmp_path: Path) -> None: workspace = initialize_workspace(tmp_path / "workspace") for index in range(25): @@ -175,6 +379,7 @@ def test_preparation_eligibility_is_explicit_and_revision_bound(tmp_path: Path) "preparation_eligible": True, "preparation_ineligible_reason": "", "preparation_source_descriptor": descriptor, + "preparation_profile": preparation_profile(eligible_source), }, ) @@ -185,6 +390,9 @@ def test_preparation_eligibility_is_explicit_and_revision_bound(tmp_path: Path) REVISION, descriptor, ) + assert controller.get_preparation_profile_available() + assert controller.get_preparation_profile_current() + assert controller.preparation_profile_snapshot() == preparation_profile(eligible_source) standalone = tmp_path / "standalone.csv" standalone.touch() @@ -198,12 +406,179 @@ def test_preparation_eligibility_is_explicit_and_revision_bound(tmp_path: Path) "preparation_eligible": False, "preparation_ineligible_reason": reason, "preparation_source_descriptor": None, + "preparation_profile": None, }, ) assert not controller.get_preparation_eligible() assert controller.get_preparation_ineligible_reason() == reason assert controller.preparation_source_snapshot() is None + assert controller.preparation_profile_snapshot() is None + coordinator.shutdown() + + +def test_preparation_profile_projects_qml_safe_typed_state(tmp_path: Path) -> None: + source = tmp_path / "generated-run" + source.mkdir() + descriptor = { + "source_path": str(source.resolve()), + "source_kind": "dataset_run", + "inspection_revision": REVISION, + "controls": {}, + "tables": [], + } + profile = preparation_profile(source) + controller, coordinator = controller_for() + + prepare_payload( + controller, + source, + { + "source_kind": "dataset", + "summary": {}, + "preparation_eligible": True, + "preparation_ineligible_reason": "", + "preparation_source_descriptor": descriptor, + "preparation_profile": profile, + }, + ) + + assert controller.get_preparation_profile_source_kind() == "dataset_run" + assert controller.get_preparation_profile_revision() == REVISION + assert controller.get_preparation_completion_status() == "completed" + assert not controller.get_preparation_partial_source() + assert controller.get_preparation_reference_model() == "heos" + assert not controller.get_preparation_model_holdout_available() + assert controller.get_preparation_model_holdout_reason() + assert controller.get_preparation_reference_context_compatible() + assert controller.get_preparation_reference_context_reason_code() == "" + assert controller.get_preparation_reference_context_reason() == "" + assert controller.preparation_models_model.rows() == ( + { + "name": "heos", + "available": True, + "declared": False, + "missing": False, + "reference": True, + }, + ) + assert controller.preparation_numeric_candidates_model.rows() == ( + { + "name": "temperature", + "column": "temperature_K", + "unit": "K", + "source": "coordinate", + "referenceDependent": False, + }, + ) + assert ( + controller.preparation_target_candidates_model.rows() + == controller.preparation_numeric_candidates_model.rows() + ) + assert controller.preparation_categorical_candidates_model.rows()[0]["name"] == "fluid" + assert controller.preparation_auxiliary_candidates_model.rows()[0]["name"] == "run_id" + assert controller.preparation_observed_categories_model.rows() == ( + {"field": "fluid", "values": ["Propane"], "count": 1}, + ) + assert controller.preparation_derived_features_model.rows()[0] == { + "name": "specific_volume", + "status": "ready", + "available": True, + "readyRowCount": 2, + "sourceRowCount": 2, + "reason": "", + "reasonCodes": [], + "missingDependencies": [], + "dependencies": ["mass_density"], + "unit": "m^3/kg", + } + assert controller.preparation_reference_contexts_model.rows()[0]["artifact"] == ( + "dataset.parquet" + ) + + snapshot = controller.preparation_profile_snapshot() + assert snapshot is not None + cast(dict[str, list[str]], snapshot["observed_category_values"])["fluid"].append("n-Butane") + assert controller.preparation_profile_snapshot() == profile + controller._mark_stale("preview became stale") + assert controller.get_preparation_profile_available() + assert not controller.get_preparation_profile_current() + assert controller.preparation_profile_snapshot() is None + coordinator.shutdown() + + +@pytest.mark.parametrize( + ("profile_key", "profile_value", "expected_issue"), + [ + ("source_path", "/another/source", "source path"), + ("source_kind", "model_sweep", "source kind"), + ("inspection_revision", "b" * 64, "revision"), + ], +) +def test_preparation_profile_identity_mismatch_is_rejected( + tmp_path: Path, + profile_key: str, + profile_value: object, + expected_issue: str, +) -> None: + source = tmp_path / "generated-run" + source.mkdir() + descriptor = { + "source_path": str(source.resolve()), + "source_kind": "dataset_run", + "inspection_revision": REVISION, + "controls": {}, + "tables": [], + } + profile = preparation_profile(source) + profile[profile_key] = profile_value + controller, coordinator = controller_for() + + prepare_payload( + controller, + source, + { + "source_kind": "dataset", + "summary": {}, + "preparation_eligible": True, + "preparation_ineligible_reason": "", + "preparation_source_descriptor": descriptor, + "preparation_profile": profile, + }, + ) + + assert controller.get_state() == "failed" + assert expected_issue in controller.get_issue() + assert not controller.get_preparation_eligible() + assert not controller.get_preparation_profile_available() + assert controller.preparation_models_model.get_count() == 0 + coordinator.shutdown() + + +def test_eligible_inspection_without_profile_is_rejected(tmp_path: Path) -> None: + source = tmp_path / "generated-run" + source.mkdir() + controller, coordinator = controller_for() + + prepare_payload( + controller, + source, + { + "source_kind": "dataset", + "summary": {}, + "preparation_eligible": True, + "preparation_ineligible_reason": "", + "preparation_source_descriptor": { + "source_path": str(source.resolve()), + "source_kind": "dataset_run", + "inspection_revision": REVISION, + }, + }, + ) + + assert controller.get_state() == "failed" + assert "required typed fields" in controller.get_issue() + assert controller.preparation_profile_snapshot() is None coordinator.shutdown() @@ -284,6 +659,78 @@ def test_arrays_project_each_logical_array_with_its_own_dtype(tmp_path: Path) -> coordinator.shutdown() +def test_preparation_audit_is_owned_by_controller_and_cleared_when_stale( + tmp_path: Path, +) -> None: + source = tmp_path / "preparation" + source.mkdir() + controller, coordinator = controller_for() + + prepare_payload(controller, source, finalized_preparation_audit_payload(source)) + + audit = controller.preparation_audit_model + assert controller.get_state() == "ready" + assert controller.property("preparationAudit") is audit + assert audit.get_available() + assert audit.get_scenario_evidence_available() + assert audit.get_leakage_evidence_available() + assert audit.scenarios.rows()[0]["name"] == "shuffle" + assert [row["partition"] for row in audit.partitions.rows()] == ["train", "test"] + assert audit.leakage_audits.rows()[0]["crossPartitionGroupCount"] == 0 + assert audit.matrix_checks.get_available() + assert audit.baseline_checks.get_available() + + controller._mark_stale("inspection revision changed") + + assert controller.get_state() == "stale" + assert not audit.get_available() + assert audit.scenarios.get_count() == 0 + assert audit.leakage_audits.get_count() == 0 + assert not controller.preparation_quality_errors_model.get_available() + coordinator.shutdown() + + +def test_inconsistent_preparation_audit_is_rejected_before_projection(tmp_path: Path) -> None: + source = tmp_path / "preparation" + source.mkdir() + controller, coordinator = controller_for() + payload = finalized_preparation_audit_payload(source) + audit = cast(dict[str, object], payload["preparation_audit"]) + audit["audit_schema_version"] = 2 + + prepare_payload(controller, source, payload) + + assert controller.get_state() == "failed" + assert "unsupported schema version" in controller.get_issue() + assert not controller.preparation_audit_model.get_available() + assert controller.preparation_audit_model.scenarios.get_count() == 0 + coordinator.shutdown() + + +def test_nonpreparation_inspection_cannot_attach_preparation_audit(tmp_path: Path) -> None: + source = tmp_path / "dataset.parquet" + source.touch() + controller, coordinator = controller_for() + + prepare_payload( + controller, + source, + { + "source_kind": "dataset", + "summary": {}, + "preparation_audit": { + "audit_schema_version": 1, + "scenario_details": [], + }, + }, + ) + + assert controller.get_state() == "failed" + assert "attached Preparation audit to another source" in controller.get_issue() + assert not controller.preparation_audit_model.get_available() + coordinator.shutdown() + + def test_first_table_preview_is_queued_after_explicit_inspection( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -326,6 +773,69 @@ def test_first_table_preview_is_queued_after_explicit_inspection( coordinator.shutdown() +def test_obsolete_inspection_failure_cannot_replace_a_newer_source_context( + tmp_path: Path, +) -> None: + first = tmp_path / "first" + second = tmp_path / "second" + controller, coordinator = stub_controller_for() + + assert controller.inspect_source(str(first)) + controller._source = second.resolve() + controller._requested_inspection_source = second.resolve() + controller._state = "loading" + coordinator.fail({"message": "obsolete inspection failed"}) + + assert controller.get_source_path() == str(second.resolve()) + assert controller.get_state() == "loading" + assert controller.get_issue() == "" + + +def test_obsolete_preview_failure_cannot_stale_a_newer_preview_context( + tmp_path: Path, +) -> None: + source = tmp_path / "dataset.parquet" + controller, coordinator = stub_controller_for() + controller._source = source.resolve() + controller._state = "ready" + controller._revision = REVISION + controller._selected_table_id = "dataset" + + assert controller._request_preview_block(0, 0) + controller._revision = "b" * 64 + controller._selected_table_id = "replacement" + controller._preview_state = "ready" + coordinator.fail({"message": "obsolete preview failed"}) + + assert controller.get_state() == "ready" + assert controller.get_preview_state() == "ready" + assert controller.get_selected_table_id() == "replacement" + assert controller.get_issue() == "" + + +def test_preview_payload_must_match_the_requested_worker_block(tmp_path: Path) -> None: + source = tmp_path / "dataset.parquet" + controller, coordinator = stub_controller_for() + controller._source = source.resolve() + controller._state = "ready" + controller._revision = REVISION + controller._selected_table_id = "dataset" + + assert controller._request_preview_block(500, 500) + coordinator.succeed( + { + "table_id": "dataset", + "block_offset": 0, + "columns": ["temperature_K"], + "rows": [[300.0]], + } + ) + + assert controller.get_state() == "stale" + assert controller.get_preview_state() == "stale" + assert "no longer matches" in controller.get_issue() + + def test_real_worker_inspection_automatically_loads_first_bounded_preview( tmp_path: Path, ) -> None: @@ -334,7 +844,9 @@ def test_real_worker_inspection_automatically_loads_first_bounded_preview( application = QApplication.instance() if not isinstance(application, QApplication): application = QApplication([]) - source = tmp_path / "dataset.parquet" + source = tmp_path / "dataset-run" + source.mkdir() + dataset = source / "dataset.parquet" pd.DataFrame( { "run_id": ["run"], @@ -356,7 +868,25 @@ def test_real_worker_inspection_automatically_loads_first_bounded_preview( "temperature_K": [300.0], "pressure_Pa": [101325.0], } - ).to_parquet(source, index=False) + ).to_parquet(dataset, index=False) + (source / "metadata.json").write_text( + json.dumps( + { + "run_id": "run", + "run_status": "completed", + "backend": "coolprop", + "backend_model": "heos", + "reference_state_policy": "coolprop_DEF", + "reference_state_backend_model": "heos", + "reference_state_targets": ["HEOS::Propane"], + "canonical_units": {"temperature_K": "K", "pressure_Pa": "Pa"}, + "artifact_hashes": { + "dataset.parquet": hashlib.sha256(dataset.read_bytes()).hexdigest() + }, + } + ), + encoding="utf-8", + ) controller, coordinator = controller_for() assert controller.inspect_source(str(source)) @@ -377,6 +907,13 @@ def quit_when_ready() -> None: assert controller.get_source_kind() == "dataset" assert controller.get_selected_table_id() == "dataset" assert controller.get_preview_state() == "ready" + assert controller.get_preparation_profile_current() + assert controller.get_preparation_profile_source_kind() == "dataset_run" + assert controller.get_preparation_reference_context_compatible() + assert [row["name"] for row in controller.preparation_numeric_candidates_model.rows()] == [ + "temperature", + "pressure", + ] assert controller.table_model.total_rows == 1 assert controller.table_model.first_row == 1 assert controller.table_model.last_row == 1 diff --git a/tests/test_app_plot_artifacts.py b/tests/test_app_plot_artifacts.py index 8cce89b..55b7091 100644 --- a/tests/test_app_plot_artifacts.py +++ b/tests/test_app_plot_artifacts.py @@ -15,8 +15,8 @@ from carnopy.app.activity_controller import ActivityController from carnopy.app.client import WorkerClient -from carnopy.app.config_controller import DatasetConfigController -from carnopy.app.config_document import DatasetConfigDocument, sha256_bytes +from carnopy.app.config_controller import ConfigurationController +from carnopy.app.config_document import ConfigurationDocument, sha256_bytes from carnopy.app.configured_plot_results_controller import ConfiguredPlotResultsController from carnopy.app.jobs import JobStore from carnopy.app.plot_artifacts import ( @@ -226,7 +226,7 @@ def test_configured_results_controller_projects_only_activity_records( ) -> None: del application workspace, record, _report_path, _sidecar_path = _configured_bundle(tmp_path) - document = DatasetConfigDocument({"schema_version": 2, "document_type": "dataset"}) + document = ConfigurationDocument({"schema_version": 2, "document_type": "dataset"}) config_path = workspace.configs / "config.yaml" config_path.write_bytes(document.yaml_bytes) document.mark_saved(config_path, document.yaml_bytes) @@ -236,7 +236,7 @@ def test_configured_results_controller_projects_only_activity_records( JobStore(workspace.private_directory).write(record) coordinator = DesktopRequestCoordinator(WorkerClient()) activity = ActivityController(coordinator) - configuration = DatasetConfigController(coordinator) + configuration = ConfigurationController(coordinator) configuration.document = document controller = ConfiguredPlotResultsController( activity, diff --git a/tests/test_app_preparation_audit.py b/tests/test_app_preparation_audit.py new file mode 100644 index 0000000..a2b4660 --- /dev/null +++ b/tests/test_app_preparation_audit.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +import copy +from collections.abc import Callable + +import pytest + +from carnopy.app.preparation_audit import ( + BASELINE_CHECK_ROLES, + BASELINE_FAILURE_ROLES, + BASELINE_METRIC_ROLES, + CORRELATED_PAIR_ROLES, + DUPLICATE_STATE_ROLES, + FEATURE_TARGET_CORRELATION_ROLES, + GRID_GROUP_ROLES, + GRID_PHASE_ROLES, + GRID_SPACING_ROLES, + LEAKAGE_ROLES, + MATRIX_CHECK_ROLES, + MATRIX_FEATURE_FLAG_ROLES, + PARTITION_ROLES, + QUALITY_OVERVIEW_ROLES, + SCENARIO_ROLES, + SINGULAR_VALUE_ROLES, + PreparationAuditProjection, +) + + +def _payload() -> dict[str, object]: + return { + "source_kind": "preparation", + "summary": { + "row_counts": {"source": 9, "eligible": 8, "excluded": 1}, + "scenarios": { + "status": "completed", + "scenario_count": 1, + "partition_count": 2, + "scenarios": [ + { + "name": "holdout", + "kind": "shuffle", + "partition_counts": {"test": 2, "train": 6}, + "transformations": [ + { + "field": "pressure", + "methods": ["standard"], + } + ], + } + ], + }, + "quality": { + "errors": [], + "summary": { + "status": "completed", + "row_counts": {"eligible": 8, "excluded": 1}, + "quality_flags": { + "artifact": "data/quality_flags.parquet", + "row_count": 2, + }, + "flags_row_count": 2, + "duplicate_state_candidates": { + "status": "completed", + "group_columns": ["fluid", "temperature", "pressure"], + "duplicate_group_count": 1, + "duplicate_row_count": 2, + "conflicting_target_group_count": 0, + }, + "structured_grid": { + "status": "completed", + "groups": [ + { + "group": { + "source_run_id": "run-1", + "source_fluid": "Propane", + "backend_model": "heos", + }, + "row_count": 8, + "expected_cells": 9, + "observed_cells": 8, + "missing_cells": 1, + "coverage_fraction": 8 / 9, + "repeated_cell_count": 1, + "repeated_row_count": 1, + "coordinate_spacing": { + "source_pressure_Pa": { + "level_count": 3, + "minimum": 100000.0, + "maximum": 300000.0, + "spacing_count": 2, + "minimum_spacing": 100000.0, + "maximum_spacing": 100000.0, + "median_spacing": 100000.0, + "maximum_to_minimum_spacing_ratio": 1.0, + "uniform_spacing": True, + }, + "source_temperature_K": { + "level_count": 3, + "minimum": 300.0, + "maximum": 320.0, + "spacing_count": 2, + "minimum_spacing": 10.0, + "maximum_spacing": 10.0, + "median_spacing": 10.0, + "maximum_to_minimum_spacing_ratio": 1.0, + "uniform_spacing": True, + }, + }, + "phase_boundaries": { + "status": "completed", + "phase_counts": {"liquid": 3, "gas": 5}, + "multi_phase_cell_count": 1, + "transition_edge_count": 2, + }, + } + ], + }, + "matrix_diagnostics": { + "status": "completed", + "fits": [ + { + "scenario": "holdout", + "fit_partition": "train", + "row_count": 6, + "feature_columns": [ + "pressure", + "pressure_copy", + "constant", + "almost_constant", + ], + "target_columns": ["mass_density", "constant_target"], + "correlation_threshold": 0.99, + "near_constant_relative_spread_threshold": 1e-12, + "standardization": "population mean and std", + "status": "completed", + "constant_feature_columns": ["constant"], + "near_constant_feature_columns": [ + {"field": "almost_constant", "relative_spread": 1e-14} + ], + "variable_feature_columns": [ + "pressure", + "pressure_copy", + "almost_constant", + ], + "singular_values": [3.0, 1.0], + "explained_variance_ratio": [0.9, 0.1], + "numerical_rank": 2, + "rank_tolerance": 1e-14, + "rank_tolerance_definition": "defined tolerance", + "feature_rank_fraction": 2 / 3, + "effective_rank": 1.4, + "effective_rank_fraction": 1.4 / 3, + "condition_number": 3.0, + "condition_number_is_infinite": False, + "highly_correlated_feature_pairs": [ + { + "left": "pressure", + "right": "pressure_copy", + "correlation": 1.0, + } + ], + "constant_target_columns": ["constant_target"], + "feature_target_correlations": [ + { + "feature": "pressure", + "target": "mass_density", + "correlation": 0.75, + } + ], + } + ], + }, + "baseline_diagnostics": { + "status": "completed_with_failures", + "fits": [ + { + "scenario": "holdout", + "status": "completed_with_failures", + "library": "scikit-learn", + "library_version": "1.8.0", + "feature_columns": ["pressure"], + "target_columns": ["mass_density"], + "train_row_count": 6, + "evaluation_row_counts": {"test": 2}, + "models": [ + { + "model": "ridge", + "target": "mass_density", + "status": "completed", + "metrics": { + "test": { + "mean_absolute_error": 0.1, + "root_mean_squared_error": 0.2, + "r_squared": None, + "actual_minimum": 1.0, + "actual_maximum": 2.0, + "prediction_minimum": 1.1, + "prediction_maximum": 1.9, + } + }, + }, + { + "model": "hist_gradient_boosting", + "target": "mass_density", + "status": "failed", + "error_type": "ValueError", + "message": "not enough rows", + }, + ], + "policy": "diagnostic metrics only", + } + ], + }, + }, + }, + }, + "preparation_audit": { + "audit_schema_version": 1, + "scenario_details": [ + { + "name": "holdout", + "state_leakage": { + "identity_column": "source_state_hash", + "duplicate_state_group_count": 1, + "cross_partition_group_count": 0, + }, + } + ], + }, + } + + +def test_projection_flattens_finalized_audit_evidence_deterministically() -> None: + projection = PreparationAuditProjection.from_worker_payload(_payload()) + + assert projection.available is True + assert projection.quality_status == "completed" + assert projection.quality_errors == () + assert projection.scenario_evidence_available is True + assert projection.leakage_evidence_available is True + assert projection.duplicate_state_evidence_available is True + assert projection.grid_evidence_available is True + assert projection.matrix_evidence_available is True + assert projection.baseline_evidence_available is True + assert projection.quality_overview[0] == { + "status": "completed", + "eligibleRowCount": 8, + "excludedRowCount": 1, + "eligibleRowCountAvailable": True, + "excludedRowCountAvailable": True, + "recordedFlagCount": 2, + "inspectedFlagCount": 2, + "recordedFlagCountAvailable": True, + "inspectedFlagCountAvailable": True, + "flagCountMatches": True, + "errorCount": 0, + "scenarioStatus": "completed", + "matrixStatus": "completed", + "baselineStatus": "completed_with_failures", + "duplicateStatus": "completed", + "gridStatus": "completed", + } + assert [row["partition"] for row in projection.partitions] == ["train", "test"] + assert projection.scenarios[0]["leakageAvailable"] is True + assert projection.leakage_audits[0]["crossPartitionGroupCount"] == 0 + assert projection.grid_spacing[0]["coordinate"] == "source_temperature_K" + assert [row["phase"] for row in projection.grid_phase_counts] == ["gas", "liquid"] + assert [row["kind"] for row in projection.matrix_feature_flags] == [ + "constant_feature", + "constant_target", + "near_constant_feature", + ] + assert projection.singular_values[1]["explainedVarianceRatio"] == pytest.approx(0.1) + assert projection.correlated_feature_pairs[0]["left"] == "pressure" + assert projection.feature_target_correlations[0]["target"] == "mass_density" + assert projection.baseline_checks[0]["failedModelCount"] == 1 + assert projection.baseline_metrics[0]["rSquaredAvailable"] is False + assert projection.baseline_failures[0]["message"] == "not enough rows" + + +def test_projection_rows_exactly_match_their_stable_role_contracts() -> None: + projection = PreparationAuditProjection.from_worker_payload(_payload()) + collections: tuple[ + tuple[tuple[dict[str, object], ...], tuple[str, ...]], + ..., + ] = ( + (projection.quality_overview, QUALITY_OVERVIEW_ROLES), + (projection.scenarios, SCENARIO_ROLES), + (projection.partitions, PARTITION_ROLES), + (projection.leakage_audits, LEAKAGE_ROLES), + (projection.duplicate_state_checks, DUPLICATE_STATE_ROLES), + (projection.grid_groups, GRID_GROUP_ROLES), + (projection.grid_spacing, GRID_SPACING_ROLES), + (projection.grid_phase_counts, GRID_PHASE_ROLES), + (projection.matrix_checks, MATRIX_CHECK_ROLES), + (projection.matrix_feature_flags, MATRIX_FEATURE_FLAG_ROLES), + (projection.singular_values, SINGULAR_VALUE_ROLES), + (projection.correlated_feature_pairs, CORRELATED_PAIR_ROLES), + (projection.feature_target_correlations, FEATURE_TARGET_CORRELATION_ROLES), + (projection.baseline_checks, BASELINE_CHECK_ROLES), + (projection.baseline_metrics, BASELINE_METRIC_ROLES), + (projection.baseline_failures, BASELINE_FAILURE_ROLES), + ) + + for rows, roles in collections: + assert rows + assert set(rows[0]) == set(roles) + + +def test_projection_represents_legacy_quality_absence_without_fabricating_rows() -> None: + projection = PreparationAuditProjection.from_worker_payload( + { + "source_kind": "preparation", + "summary": { + "quality": {"artifacts": {"report": None, "flags": None}, "summary": {}}, + }, + } + ) + + assert projection.available is False + assert projection.quality_status == "absent" + assert projection.scenario_evidence_available is False + assert projection.leakage_evidence_available is False + assert projection.quality_overview[0]["status"] == "absent" + assert projection.scenarios == () + assert projection.matrix_checks == () + assert projection.baseline_checks == () + + +def test_projection_accepts_current_not_requested_and_skipped_sections() -> None: + payload = _payload() + summary = _summary(payload) + summary["scenarios"] = None + del payload["preparation_audit"] + quality = _quality_summary(summary) + quality["duplicate_state_candidates"] = { + "status": "skipped_missing_identity_columns", + "group_columns": [], + } + quality["structured_grid"] = { + "status": "skipped_unsupported_mode", + "source_mode": "saturation", + "reason": "source sampler metadata is unavailable", + } + quality["matrix_diagnostics"] = {"status": "not_requested", "fits": []} + quality["baseline_diagnostics"] = {"status": "not_requested", "fits": []} + + projection = PreparationAuditProjection.from_worker_payload(payload) + + assert projection.available is True + assert projection.scenarios == () + assert projection.duplicate_state_checks[0]["countsAvailable"] is False + assert projection.grid_groups == () + assert projection.matrix_checks == () + assert projection.baseline_checks == () + assert projection.quality_overview[0]["gridStatus"] == "skipped_unsupported_mode" + + +def test_projection_retains_verified_flag_count_when_report_is_unavailable() -> None: + projection = PreparationAuditProjection.from_worker_payload( + { + "source_kind": "preparation", + "summary": { + "quality": { + "errors": [], + "summary": {"status": "unavailable", "flags_row_count": 3}, + }, + }, + } + ) + + assert projection.available is True + assert projection.quality_overview[0]["inspectedFlagCount"] == 3 + assert projection.quality_overview[0]["recordedFlagCountAvailable"] is False + assert projection.quality_overview[0]["flagCountMatches"] is False + + +def test_projection_does_not_invent_unavailable_scenario_leakage() -> None: + payload = _payload() + del payload["preparation_audit"] + + projection = PreparationAuditProjection.from_worker_payload(payload) + + assert projection.scenarios[0]["leakageAvailable"] is False + assert projection.leakage_audits == () + + +def test_projection_detaches_list_roles_from_worker_payload() -> None: + payload = _payload() + projection = PreparationAuditProjection.from_worker_payload(payload) + groups = cast_string_list( + cast_dict(_quality_summary(_summary(payload))["duplicate_state_candidates"])[ + "group_columns" + ] + ) + groups.clear() + + assert projection.duplicate_state_checks[0]["groupColumns"] == [ + "fluid", + "temperature", + "pressure", + ] + + +def test_projection_preserves_verified_report_when_flags_are_inconsistent() -> None: + payload = _payload() + summary = _summary(payload) + quality = _quality_summary(summary) + quality["flags_row_count"] = 1 + cast_errors = cast_string_list(_quality(summary)["errors"]) + cast_errors.append("quality flags artifact row count is inconsistent") + + projection = PreparationAuditProjection.from_worker_payload(payload) + + assert projection.available is True + assert projection.quality_overview[0]["flagCountMatches"] is False + assert projection.quality_overview[0]["errorCount"] == 1 + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda payload: cast_dict(_summary(payload)["row_counts"]).update({"eligible": 7}), + "eligible row counts are inconsistent", + ), + ( + lambda payload: cast_dict(_summary(payload)["scenarios"]).update({"scenario_count": 2}), + "scenario count is inconsistent", + ), + ( + lambda payload: cast_dict( + cast_list(cast_dict(_summary(payload)["scenarios"])["scenarios"])[0][ + "partition_counts" + ] + ).update({"train": 5}), + "does not contain every eligible row", + ), + ( + lambda payload: cast_list(cast_dict(payload["preparation_audit"])["scenario_details"])[ + 0 + ].update({"name": "unknown"}), + "do not match the scenario summary", + ), + ( + lambda payload: cast_dict( + cast_list( + cast_dict(_quality_summary(_summary(payload))["matrix_diagnostics"])["fits"] + )[0] + ).update({"explained_variance_ratio": [1.0]}), + "singular values and variance ratios differ", + ), + ], +) +def test_projection_rejects_inconsistent_worker_evidence( + mutate: Callable[[dict[str, object]], None], + message: str, +) -> None: + payload = copy.deepcopy(_payload()) + mutate(payload) + + with pytest.raises(ValueError, match=message): + PreparationAuditProjection.from_worker_payload(payload) + + +def _summary(payload: dict[str, object]) -> dict[str, object]: + return cast_dict(payload["summary"]) + + +def _quality(summary: dict[str, object]) -> dict[str, object]: + return cast_dict(summary["quality"]) + + +def _quality_summary(summary: dict[str, object]) -> dict[str, object]: + return cast_dict(_quality(summary)["summary"]) + + +def cast_dict(value: object) -> dict[str, object]: + assert isinstance(value, dict) + return value + + +def cast_list(value: object) -> list[dict[str, object]]: + assert isinstance(value, list) + assert all(isinstance(item, dict) for item in value) + return value + + +def cast_string_list(value: object) -> list[str]: + assert isinstance(value, list) + assert all(isinstance(item, str) for item in value) + return value diff --git a/tests/test_app_preparation_audit_models.py b/tests/test_app_preparation_audit_models.py new file mode 100644 index 0000000..5d9dcb1 --- /dev/null +++ b/tests/test_app_preparation_audit_models.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from carnopy.app.preparation_audit import PreparationAuditProjection +from carnopy.app.preparation_audit_models import PreparationAuditModel + + +def audit_payload() -> dict[str, object]: + return { + "source_kind": "preparation", + "summary": { + "row_counts": {"source": 2, "eligible": 2, "excluded": 0}, + "scenarios": { + "status": "completed", + "scenario_count": 1, + "partition_count": 2, + "scenarios": [ + { + "name": "shuffle", + "kind": "shuffle", + "partition_counts": {"test": 1, "train": 1}, + "transformations": [], + } + ], + }, + "quality": { + "errors": ["one advisory artifact issue"], + "summary": { + "status": "completed", + "row_counts": {"eligible": 2, "excluded": 0}, + "quality_flags": {"row_count": 1}, + "flags_row_count": 1, + "duplicate_state_candidates": { + "status": "skipped_missing_identity_columns", + "group_columns": [], + }, + "structured_grid": { + "status": "skipped_unsupported_mode", + "source_mode": "saturation", + }, + "matrix_diagnostics": {"status": "not_requested", "fits": []}, + "baseline_diagnostics": {"status": "not_requested", "fits": []}, + }, + }, + }, + "preparation_audit": { + "audit_schema_version": 1, + "scenario_details": [ + { + "name": "shuffle", + "state_leakage": { + "identity_column": "source_state_hash", + "duplicate_state_group_count": 0, + "cross_partition_group_count": 0, + }, + } + ], + }, + } + + +def test_audit_model_owns_typed_rows_and_section_availability() -> None: + projection = PreparationAuditProjection.from_worker_payload(audit_payload()) + model = PreparationAuditModel() + + model.replace(projection) + + assert model.get_available() + assert model.get_quality_status() == "completed" + assert model.property("available") is True + assert model.property("qualityStatus") == "completed" + assert model.property("scenariosModel") is model.scenarios + assert model.property("leakageAudits") is model.leakage_audits + assert model.get_scenario_evidence_available() + assert model.get_leakage_evidence_available() + assert model.quality_overview.get(0)["flagCountMatches"] is True + assert [row["partition"] for row in model.partitions.rows()] == ["train", "test"] + assert model.leakage_audits.get(0)["crossPartitionGroupCount"] == 0 + assert model.matrix_checks.get_count() == 0 + assert model.matrix_checks.get_available() + assert model.baseline_checks.get_count() == 0 + assert model.baseline_checks.get_available() + + +def test_audit_model_clear_removes_rows_and_availability() -> None: + model = PreparationAuditModel() + model.replace(PreparationAuditProjection.from_worker_payload(audit_payload())) + + model.clear() + + assert not model.get_available() + assert model.get_quality_status() == "" + assert not model.get_scenario_evidence_available() + assert not model.get_leakage_evidence_available() + for child in model._models(): + assert child.get_count() == 0 + assert not child.get_available() diff --git a/tests/test_app_preparation_draft.py b/tests/test_app_preparation_draft.py new file mode 100644 index 0000000..7a306b2 --- /dev/null +++ b/tests/test_app_preparation_draft.py @@ -0,0 +1,732 @@ +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest +import yaml + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from carnopy.app.preparation_draft import PreparationDraft +from carnopy.preparation.models import PreparationConfig +from carnopy.templates import template_text + + +def _payload() -> dict[str, Any]: + value = yaml.safe_load(template_text(cast(Any, "preparation"))) + assert isinstance(value, dict) + return cast(dict[str, Any], value) + + +def _normalized(payload: dict[str, Any]) -> dict[str, Any]: + return PreparationConfig.model_validate(payload).model_dump( + mode="json", + exclude_none=True, + ) + + +def _field(name: str, *, reference_dependent: bool = False) -> dict[str, object]: + return { + "name": name, + "column": f"{name}_column", + "unit": "K" if name == "temperature" else None, + "source": "property", + "reference_dependent": reference_dependent, + } + + +def _profile(*, complete: bool = True) -> dict[str, object]: + numeric_names = ( + ["temperature", "pressure", "mass_density", "specific_enthalpy"] + if complete + else ["pressure", "specific_enthalpy"] + ) + derived = [ + { + "name": name, + "status": "ready" if complete or name != "specific_volume" else "unavailable", + "available": complete or name != "specific_volume", + "reason": ( + "Density is unavailable in this source." + if not complete and name == "specific_volume" + else "" + ), + "unit": "1", + } + for name in ( + "specific_volume", + "reduced_temperature", + "reduced_pressure", + "compressibility_factor", + ) + ] + auxiliary_names = ( + ["fluid", "backend_model", "phase", "run_id", "case_id"] if complete else ["fluid"] + ) + numeric = [ + _field(name, reference_dependent=name == "specific_enthalpy") for name in numeric_names + ] + return { + "source_kind": "dataset_run", + "completion": {"status": "completed", "partial": False}, + "available_models": ["heos"], + "numeric_candidates": numeric, + "target_candidates": list(numeric), + "categorical_candidates": [_field("phase"), _field("fluid")], + "auxiliary_candidates": [_field(name) for name in auxiliary_names], + "observed_category_values": { + "phase": ["gas", "liquid"], + "fluid": ["Propane"], + }, + "derived_features": derived, + "model_holdout": { + "available": False, + "reason": "Model holdout scenarios require a model-sweep source.", + }, + "reference_context": {"compatible": True, "reason": ""}, + } + + +def _capabilities( + *, + safetensors: bool, + analysis: bool, +) -> dict[str, object]: + return { + "workflows": { + "preparation": { + "safetensors": { + "available": safetensors, + "guidance": "install ml", + }, + "baseline_diagnostics": { + "available": analysis, + "guidance": "install analysis", + }, + } + } + } + + +def test_preparation_role_draft_round_trips_and_preserves_deferred_sections() -> None: + payload = _payload() + payload["scenarios"] = [{"name": "all", "kind": "unsplit"}] + payload["quality"] = { + "matrix_diagnostics": { + "correlation_threshold": 0.99, + "near_constant_relative_spread": 1e-10, + } + } + payload["outputs"] = { + "formats": ["parquet"], + "parquet": True, + "arrays": { + "formats": ["npy", "npz"], + "dtype": "float64", + "include_auxiliary": True, + }, + } + expected = _normalized(payload) + draft = PreparationDraft() + + draft.load_payload(payload) + + assert draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.payload() == expected + assert draft.payload()["scenarios"] == expected["scenarios"] + assert draft.payload()["quality"] == expected["quality"] + assert draft.payload()["outputs"] == expected["outputs"] + + +def test_preparation_role_edits_are_explicit_ordered_and_dirty() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + messages: list[str] = [] + draft.message.connect(messages.append) + + assert not draft.set_role_selected("target", "temperature", True) + assert messages == ["Already selected for an incompatible Preparation role."] + assert draft.set_role_selected("numeric", "temperature", False) + assert draft.set_role_selected("target", "temperature", True) + assert draft.set_role_selected("auxiliary", "case_id", False) + assert draft.set_allow_partial_sweep(True) + assert draft.set_category_mode("phase", "explicit") + assert draft.set_explicit_categories("phase", "gas, liquid") + assert not draft.set_explicit_categories("phase", "gas, gas") + assert messages[-1] == "Explicit categories must be unique." + assert not draft.set_explicit_categories("phase", "gas, , liquid") + assert messages[-1] == "Explicit categories must not contain blank values." + assert draft.explicit_categories_text("phase") == "gas, liquid" + assert not draft.set_category_mode("phase", "observed") + assert "Confirm replacing" in messages[-1] + assert draft.set_category_mode("phase", "observed", True) + + value = draft.payload() + assert value["source_policy"] == {"allow_partial_sweep": True} + assert value["features"]["numeric"] == ["pressure", "mass_density"] + assert value["targets"] == ["specific_enthalpy", "temperature"] + assert value["auxiliary"] == ["fluid", "backend_model", "phase", "run_id"] + assert value["categorical_features"][0]["categories"] == "observed" + assert draft.observed_categories("phase") == [] + assert draft.get_dirty() + draft.mark_baseline() + assert not draft.get_dirty() + + +def test_source_profile_updates_choices_without_dirtying_or_rewriting_yaml() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + baseline = draft.payload() + changes: list[str] = [] + profiles: list[str] = [] + draft.changed.connect(lambda: changes.append("changed")) + draft.profile_changed.connect(lambda: profiles.append("profile")) + + assert draft.apply_source_profile(_profile(complete=False)) + + assert changes == [] + assert profiles == ["profile"] + assert draft.payload() == baseline + assert not draft.get_dirty() + assert draft.get_locally_valid() + assert "temperature" in draft.get_source_issue() + temperature = next(item for item in draft.numeric_choices.items if item.value == "temperature") + volume = next(item for item in draft.derived_choices.items if item.value == "specific_volume") + assert temperature.selected and not temperature.compatible + assert volume.selected and not volume.compatible + assert not draft.set_role_selected("numeric", "mass_density", True) + assert draft.payload() == baseline + assert not draft.apply_source_profile(_profile(complete=False)) + assert profiles == ["profile"] + + +def test_profile_candidates_expose_roles_categories_and_reference_context() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + profile = _profile() + draft.apply_source_profile(profile) + + assert draft.get_profile_available() + assert draft.get_source_kind() == "dataset_run" + assert draft.get_source_issue() == "" + assert draft.observed_categories("phase") == ["gas", "liquid"] + temperature = next(item for item in draft.numeric_choices.items if item.value == "temperature") + assert temperature.label == "temperature_column" + assert temperature.unit == "K" + assert all(item.compatible for item in draft.categorical_choices.items) + + incompatible = dict(profile) + incompatible["reference_context"] = { + "compatible": False, + "reason": "Reference contexts disagree.", + } + draft.apply_source_profile(incompatible) + assert draft.get_source_issue() == "Reference contexts disagree." + + +def test_partial_sweep_policy_is_source_blocking_but_remains_saveable_yaml() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + profile = _profile() + profile["source_kind"] = "model_sweep" + profile["completion"] = {"status": "partial", "partial": True} + + draft.apply_source_profile(profile) + + assert "partial" in draft.get_source_issue().casefold() + assert draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.set_allow_partial_sweep(True) + assert draft.get_source_issue() == "" + assert draft.get_locally_valid() + assert draft.get_dirty() + + +def test_invalid_local_roles_keep_raw_dirty_state_and_stable_focus() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + + assert draft.set_role_selected("target", "specific_enthalpy", False) + assert not draft.get_locally_valid() + assert draft.get_dirty() + assert draft.get_first_invalid_field() == "preparation.targets" + with pytest.raises(ValueError, match="invalid ML Preparation draft"): + draft.mark_baseline() + + assert draft.set_role_selected("target", "specific_enthalpy", True) + assert draft.get_locally_valid() + assert not draft.get_dirty() + + +def test_clear_removes_document_state_but_retains_source_projection() -> None: + draft = PreparationDraft() + draft.apply_source_profile(_profile()) + draft.load_payload(_payload()) + + draft.clear() + + assert not draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.get_profile_available() + assert draft.get_source_kind() == "dataset_run" + + +def test_committed_scenarios_round_trip_with_concise_ordered_summaries() -> None: + payload = _payload() + payload["scenarios"] = [ + {"name": "all", "kind": "unsplit"}, + { + "name": "random", + "kind": "shuffle", + "seed": 42, + "partitions": {"train": 0.8, "test": 0.2}, + }, + { + "name": "fluid-test", + "kind": "leave_fluid_out", + "holdouts": {"test": ["Propane"]}, + "remainder": "train", + "transformations": [{"field": "pressure", "methods": ["standard"]}], + }, + ] + draft = PreparationDraft() + + draft.load_payload(payload) + + assert draft.scenarios_model.rows() == ( + {"name": "all", "kind": "unsplit", "summary": "Unsplit · All partition"}, + { + "name": "random", + "kind": "shuffle", + "summary": "Shuffle · Train 80% · Test 20% · Seed 42", + }, + { + "name": "fluid-test", + "kind": "leave_fluid_out", + "summary": "Leave Fluid Out · Holdouts Test · 1 transformation", + }, + ) + assert draft.scenario_payloads() == tuple(_normalized(payload)["scenarios"]) + assert draft.payload() == _normalized(payload) + assert not draft.get_dirty() + + +def test_scenario_editor_is_transient_until_explicit_commit_or_cancel() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + committed = draft.payload() + document_changes: list[str] = [] + dirty_changes: list[str] = [] + active_changes: list[str] = [] + messages: list[str] = [] + draft.changed.connect(lambda: document_changes.append("changed")) + draft.dirty_changed.connect(lambda: dirty_changes.append("dirty")) + draft.active_scenario_draft_changed.connect(lambda: active_changes.append("active")) + draft.message.connect(messages.append) + + assert draft.begin_add_scenario() + assert draft.get_has_active_scenario_edit() + assert draft.get_active_scenario_row() == -1 + assert active_changes == ["active"] + active = draft.get_active_scenario_draft() + assert active is not None + assert active.set_name("not a slug") + assert draft.payload() == committed + assert not draft.get_dirty() + assert document_changes == [] + assert dirty_changes == [] + + assert not draft.commit_scenario() + assert messages and "safe slugs" in messages[-1] + assert draft.get_has_active_scenario_edit() + assert active.set_name("evaluation") + assert draft.commit_scenario() + assert not draft.get_has_active_scenario_edit() + assert active_changes == ["active", "active"] + assert document_changes == ["changed"] + assert dirty_changes == ["dirty"] + assert draft.get_dirty() + assert [item["name"] for item in draft.scenario_payloads()] == ["evaluation"] + + draft.mark_baseline() + document_changes.clear() + dirty_changes.clear() + assert draft.begin_edit_scenario(0) + active = draft.get_active_scenario_draft() + assert active is not None + assert active.set_name("temporary") + assert not draft.get_dirty() + assert draft.cancel_scenario() + assert document_changes == [] + assert dirty_changes == [] + assert not draft.get_dirty() + assert draft.scenario_payloads()[0]["name"] == "evaluation" + + +def test_identical_scenario_commit_closes_editor_without_dirtying_document() -> None: + payload = _payload() + payload["scenarios"] = [{"name": "all", "kind": "unsplit"}] + draft = PreparationDraft() + draft.load_payload(payload) + document_changes: list[str] = [] + draft.changed.connect(lambda: document_changes.append("changed")) + + assert draft.begin_edit_scenario(0) + assert draft.commit_scenario() + + assert not draft.get_has_active_scenario_edit() + assert not draft.get_dirty() + assert document_changes == [] + + +def test_scenario_names_order_and_removal_are_committed_deterministically() -> None: + payload = _payload() + payload["scenarios"] = [ + {"name": "all", "kind": "unsplit"}, + { + "name": "random", + "kind": "shuffle", + "seed": 42, + "partitions": {"train": 0.8, "test": 0.2}, + }, + ] + draft = PreparationDraft() + draft.load_payload(payload) + messages: list[str] = [] + draft.message.connect(messages.append) + + assert draft.begin_edit_scenario(0) + active = draft.get_active_scenario_draft() + assert active is not None + assert active.set_name("random") + assert not draft.commit_scenario() + assert messages[-1] == "Preparation scenario names must be unique." + assert draft.get_has_active_scenario_edit() + assert not draft.remove_scenario(1) + assert not draft.move_scenario(0, 1) + assert draft.cancel_scenario() + + assert draft.move_scenario(1, 0) + assert [item["name"] for item in draft.payload()["scenarios"]] == ["random", "all"] + assert draft.remove_scenario(1) + assert [item["name"] for item in draft.scenario_payloads()] == ["random"] + assert draft.get_dirty() + + +def test_source_profile_refreshes_active_scenario_choices_without_document_change() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + assert draft.begin_add_scenario() + active = draft.get_active_scenario_draft() + assert active is not None + baseline = draft.payload() + document_changes: list[str] = [] + draft.changed.connect(lambda: document_changes.append("changed")) + profile = _profile() + numeric = list(cast(list[dict[str, object]], profile["numeric_candidates"])) + numeric.append(_field("source_only_numeric")) + profile["numeric_candidates"] = numeric + + assert draft.apply_source_profile(profile) + + assert "source_only_numeric" in active.get_field_choices() + assert draft.payload() == baseline + assert not draft.get_dirty() + assert document_changes == [] + + +def test_clear_discards_active_scenario_without_committing_it() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + active_changes: list[str] = [] + draft.active_scenario_draft_changed.connect(lambda: active_changes.append("active")) + assert draft.begin_add_scenario() + active = draft.get_active_scenario_draft() + assert active is not None + assert active.set_name("temporary") + + draft.clear() + + assert not draft.get_has_active_scenario_edit() + assert draft.scenario_payloads() == () + assert active_changes == ["active", "active"] + + +def test_model_holdout_requires_compatible_bound_sweep_source_for_new_commit() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + draft.apply_source_profile(_profile()) + messages: list[str] = [] + draft.message.connect(messages.append) + + assert not draft.get_model_holdout_available() + assert "model-sweep source" in draft.get_model_holdout_issue().casefold() + assert draft.begin_add_scenario() + active = draft.get_active_scenario_draft() + assert active is not None + assert active.set_name("model-test") + assert active.request_kind_change("model_holdout") + assert active.set_categorical_holdout("test", "pr") + + assert not draft.commit_scenario() + assert messages[-1] == "Model holdout scenarios require a model-sweep source." + assert draft.get_has_active_scenario_edit() + assert draft.scenario_payloads() == () + + sweep_profile = _profile() + sweep_profile["source_kind"] = "model_sweep" + sweep_profile["available_models"] = ["heos", "pr"] + sweep_profile["model_holdout"] = {"available": True, "reason": ""} + assert draft.apply_source_profile(sweep_profile) + assert draft.get_model_holdout_available() + assert draft.commit_scenario() + assert draft.get_source_issue() == "" + assert draft.scenario_payloads()[0]["kind"] == "model_holdout" + + +def test_imported_source_incompatible_scenarios_remain_clean_and_blocking() -> None: + payload = _payload() + payload["scenarios"] = [ + { + "name": "model-test", + "kind": "model_holdout", + "holdouts": {"test": ["pr"]}, + "remainder": "train", + }, + { + "name": "fluid-test", + "kind": "leave_fluid_out", + "holdouts": {"test": ["n-Butane"]}, + "remainder": "train", + }, + ] + draft = PreparationDraft() + draft.load_payload(payload) + baseline = draft.payload() + + draft.apply_source_profile(_profile()) + + assert "model-sweep source" in draft.get_source_issue().casefold() + assert draft.payload() == baseline + assert not draft.get_dirty() + assert draft.begin_edit_scenario(0) + assert draft.commit_scenario() + assert not draft.get_has_active_scenario_edit() + assert not draft.get_dirty() + + +@pytest.mark.parametrize( + ("kind", "value", "expected_label"), + [ + ("leave_fluid_out", "n-Butane", "fluid"), + ("phase_holdout", "supercritical", "phase"), + ], +) +def test_imported_unobserved_categorical_holdouts_remain_clean_and_blocking( + kind: str, + value: str, + expected_label: str, +) -> None: + payload = _payload() + payload["scenarios"] = [ + { + "name": "categorical-test", + "kind": kind, + "holdouts": {"test": [value]}, + "remainder": "train", + } + ] + draft = PreparationDraft() + draft.load_payload(payload) + baseline = draft.payload() + + draft.apply_source_profile(_profile()) + + issue = draft.get_source_issue() + assert expected_label in issue.casefold() + assert value in issue + assert draft.payload() == baseline + assert not draft.get_dirty() + + +def test_unavailable_bound_holdout_values_are_rejected_without_mutation() -> None: + draft = PreparationDraft() + draft.load_payload(_payload()) + sweep_profile = _profile() + sweep_profile["source_kind"] = "model_sweep" + sweep_profile["available_models"] = ["heos", "pr"] + sweep_profile["model_holdout"] = {"available": True, "reason": ""} + draft.apply_source_profile(sweep_profile) + messages: list[str] = [] + draft.message.connect(messages.append) + assert draft.begin_add_scenario() + active = draft.get_active_scenario_draft() + assert active is not None + assert active.set_name("missing-model") + assert active.request_kind_change("model_holdout") + assert active.set_categorical_holdout("test", "srk") + + assert not draft.commit_scenario() + + assert "unavailable backend model holdout values: srk" in messages[-1].casefold() + assert draft.scenario_payloads() == () + assert draft.get_has_active_scenario_edit() + + +def test_preparation_output_and_quality_settings_round_trip_completely() -> None: + draft = PreparationDraft() + draft.apply_capabilities(_capabilities(safetensors=True, analysis=True)) + draft.load_payload(_payload()) + + assert draft.set_array_outputs_enabled(True) + assert draft.set_array_format_selected("npy", True) + assert draft.set_array_format_selected("safetensors", True) + assert draft.set_array_dtype("float64") + assert draft.set_include_auxiliary(True) + assert draft.set_matrix_enabled(True) + assert draft.set_correlation_threshold("0.98") + assert draft.set_near_constant_spread("2e-10") + assert draft.set_baseline_enabled(True) + assert draft.set_baseline_model_selected("hist_gradient_boosting", True) + assert draft.set_baseline_seed("7") + assert draft.set_ridge_alpha("0.25") + assert draft.set_histogram_iterations("250") + + value = draft.payload() + assert value["outputs"] == { + "formats": ["parquet"], + "parquet": True, + "arrays": { + "formats": ["npy", "npz", "safetensors"], + "dtype": "float64", + "include_auxiliary": True, + }, + } + assert value["quality"] == { + "matrix_diagnostics": { + "correlation_threshold": 0.98, + "near_constant_relative_spread": 2e-10, + }, + "baseline_diagnostics": { + "models": ["dummy_mean", "ridge", "hist_gradient_boosting"], + "random_seed": 7, + "ridge_alpha": 0.25, + "histogram_max_iterations": 250, + }, + } + assert draft.get_dependency_issue() == "" + assert draft.get_dirty() + + reloaded = PreparationDraft() + reloaded.apply_capabilities(_capabilities(safetensors=True, analysis=True)) + reloaded.load_payload(value) + assert reloaded.payload() == value + assert not reloaded.get_dirty() + + +def test_imported_unavailable_optional_requests_remain_visible_and_saveable() -> None: + payload = _payload() + payload["outputs"] = { + "formats": ["parquet"], + "arrays": {"formats": ["safetensors"], "dtype": "float32"}, + } + payload["quality"] = {"baseline_diagnostics": {"models": ["ridge"]}} + draft = PreparationDraft() + draft.apply_capabilities(_capabilities(safetensors=False, analysis=False)) + dependency_notifications: list[str] = [] + draft.capability_changed.connect(lambda: dependency_notifications.append("dependency")) + + draft.load_payload(payload) + + assert dependency_notifications == ["dependency"] + assert draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.payload() == _normalized(payload) + assert draft.get_dependency_issue() == "install ml" + safetensors = next( + item for item in draft.array_format_choices.items if item.value == "safetensors" + ) + ridge = next(item for item in draft.baseline_model_choices.items if item.value == "ridge") + assert safetensors.selected and not safetensors.compatible + assert ridge.selected and not ridge.compatible + + assert draft.set_array_format_selected("safetensors", False) + assert draft.get_dependency_issue() == "install analysis" + assert draft.set_baseline_enabled(False) + assert draft.get_dependency_issue() == "" + assert draft.get_locally_valid() + + +def test_unavailable_optional_features_cannot_be_newly_selected() -> None: + draft = PreparationDraft() + draft.apply_capabilities(_capabilities(safetensors=False, analysis=False)) + draft.load_payload(_payload()) + messages: list[str] = [] + draft.message.connect(messages.append) + + assert not draft.get_baseline_available() + assert draft.get_baseline_guidance() == "install analysis" + assert not draft.set_array_format_selected("safetensors", True) + assert messages == ["install ml"] + assert not draft.set_baseline_enabled(True) + assert messages == ["install ml", "install analysis"] + assert draft.set_array_outputs_enabled(True) + assert draft.payload()["outputs"]["arrays"]["formats"] == ["npz"] + + +def test_capability_refresh_changes_dependency_projection_without_dirtying_yaml() -> None: + payload = _payload() + payload["outputs"] = { + "formats": ["parquet"], + "arrays": {"formats": ["safetensors"], "dtype": "float32"}, + } + draft = PreparationDraft() + draft.apply_capabilities(_capabilities(safetensors=False, analysis=False)) + draft.load_payload(payload) + baseline = draft.payload() + changes: list[str] = [] + capabilities: list[str] = [] + draft.changed.connect(lambda: changes.append("changed")) + draft.capability_changed.connect(lambda: capabilities.append("capability")) + + assert draft.apply_capabilities(_capabilities(safetensors=True, analysis=False)) + + assert changes == [] + assert capabilities == ["capability"] + assert draft.get_baseline_guidance() == "install analysis" + assert draft.get_dependency_issue() == "" + assert draft.payload() == baseline + assert not draft.get_dirty() + assert not draft.apply_capabilities(_capabilities(safetensors=True, analysis=False)) + + +@pytest.mark.parametrize( + ("setter", "value", "field"), + [ + ("set_correlation_threshold", "1.1", "preparation.quality.matrix_diagnostics"), + ("set_near_constant_spread", "0", "preparation.quality.matrix_diagnostics"), + ("set_ridge_alpha", "nan", "preparation.quality.baseline_diagnostics"), + ("set_histogram_iterations", "0", "preparation.quality.baseline_diagnostics"), + ], +) +def test_invalid_quality_text_preserves_dirty_state_and_stable_focus( + setter: str, + value: str, + field: str, +) -> None: + payload = _payload() + payload["quality"] = { + "matrix_diagnostics": {}, + "baseline_diagnostics": {"models": ["dummy_mean", "ridge"]}, + } + draft = PreparationDraft() + draft.apply_capabilities(_capabilities(safetensors=True, analysis=True)) + draft.load_payload(payload) + + assert getattr(draft, setter)(value) + assert not draft.get_locally_valid() + assert draft.get_dirty() + assert draft.get_first_invalid_field() == field diff --git a/tests/test_app_qml_config.py b/tests/test_app_qml_config.py index 377ff62..80f9a61 100644 --- a/tests/test_app_qml_config.py +++ b/tests/test_app_qml_config.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +import yaml os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") pytest.importorskip("PySide6") @@ -12,8 +13,10 @@ from PySide6.QtQuick import QQuickWindow from PySide6.QtWidgets import QApplication +from carnopy.app.config_document import new_document from carnopy.app.qml_runtime import QmlApplicationRuntime, create_qml_runtime from carnopy.app.workspace import initialize_workspace +from carnopy.templates import template_text @pytest.fixture @@ -45,6 +48,34 @@ def _process_events() -> None: application.processEvents() +def test_global_shell_routes_sweep_documents_to_the_structured_editor( + runtime: QmlApplicationRuntime, +) -> None: + root = runtime.engine.rootObjects()[0] + payload = yaml.safe_load(template_text("model_sweep")) + + assert runtime.controller.configuration_controller.open_document(new_document(payload)) + _process_events() + + assert runtime.controller.configuration_controller.get_document_kind() == "model_sweep" + assert root.property("currentPage") == "sweeps" + assert root.findChild(QObject, "modelSweepPage") is not None + + +def test_global_shell_routes_preparation_documents_to_the_structured_editor( + runtime: QmlApplicationRuntime, +) -> None: + root = runtime.engine.rootObjects()[0] + payload = yaml.safe_load(template_text("preparation")) + + assert runtime.controller.configuration_controller.open_document(new_document(payload)) + _process_events() + + assert runtime.controller.configuration_controller.get_document_kind() == "preparation" + assert root.property("currentPage") == "preparation" + assert root.findChild(QObject, "preparationPage") is not None + + def _wait_for_idle(runtime: QmlApplicationRuntime) -> None: if not runtime.controller.request_coordinator.is_busy: _process_events() @@ -69,7 +100,7 @@ def test_yaml_page_projects_only_current_authoritative_document( assert root.setProperty("currentPage", "yaml") _process_events() - controller = desktop.dataset_config_controller + controller = desktop.configuration_controller page = root.findChild(QObject, "yamlPreviewPage") viewer = root.findChild(QObject, "yamlLineNumberedText") source = root.findChild(QObject, "yamlSourceText") @@ -106,7 +137,7 @@ def test_inspector_runs_one_revision_bound_standalone_validation( runtime: QmlApplicationRuntime, ) -> None: desktop = runtime.controller - controller = desktop.dataset_config_controller + controller = desktop.configuration_controller root = runtime.engine.rootObjects()[0] assert isinstance(root, QQuickWindow) assert desktop.request_new_dataset("property_table") @@ -138,7 +169,7 @@ def test_inspector_validation_is_blocked_by_local_invalidity( runtime: QmlApplicationRuntime, ) -> None: desktop = runtime.controller - controller = desktop.dataset_config_controller + controller = desktop.configuration_controller root = runtime.engine.rootObjects()[0] assert desktop.request_new_dataset("property_table") controller.dataset_draft.set_output_selected("csv", False) @@ -166,7 +197,7 @@ def test_invalid_yaml_state_is_empty_and_routes_by_structured_field( desktop.dataset_draft.set_output_selected("parquet", False) _process_events() - controller = desktop.dataset_config_controller + controller = desktop.configuration_controller banner = root.findChild(QObject, "yamlBlockingBanner") assert banner is not None assert not controller.get_yaml_available() @@ -191,7 +222,7 @@ def test_invalid_yaml_state_is_empty_and_routes_by_structured_field( def test_typed_operation_feedback_is_persistent_until_success_or_dismissal( runtime: QmlApplicationRuntime, ) -> None: - controller = runtime.controller.dataset_config_controller + controller = runtime.controller.configuration_controller root = runtime.engine.rootObjects()[0] feedback = root.findChild(QObject, "operationFeedback") toast = root.findChild(QObject, "toastHost") @@ -228,16 +259,16 @@ def test_dirty_close_configuration_uses_qml_decision_and_facade( dialog = root.findChild(QObject, "configurationDiscardDialog") assert command_bar is not None assert dialog is not None - assert desktop.dataset_config_controller.get_dirty() + assert desktop.configuration_controller.get_dirty() command_bar.closeConfigurationRequested.emit() _process_events() assert dialog.property("opened") is True - assert desktop.dataset_config_controller.get_has_document() + assert desktop.configuration_controller.get_has_document() dialog.accept() _process_events() - assert not desktop.dataset_config_controller.get_has_document() + assert not desktop.configuration_controller.get_has_document() assert root.property("currentPage") == "dataset" @@ -245,7 +276,7 @@ def test_qml_save_command_keeps_worker_validation_and_reformat_authoritative( runtime: QmlApplicationRuntime, ) -> None: desktop = runtime.controller - controller = desktop.dataset_config_controller + controller = desktop.configuration_controller root = runtime.engine.rootObjects()[0] workspace = controller.workspace assert workspace is not None diff --git a/tests/test_app_qml_dataset.py b/tests/test_app_qml_dataset.py index b4422a4..5264c2d 100644 --- a/tests/test_app_qml_dataset.py +++ b/tests/test_app_qml_dataset.py @@ -134,7 +134,7 @@ def test_new_mode_card_opens_real_dataset_page_bound_to_authoritative_draft( names = _visual_object_names(root) assert "samplerEditor-temperature" in names assert "samplerEditor-pressure" in names - assert desktop.dataset_config_controller.get_has_document() + assert desktop.configuration_controller.get_has_document() assert desktop.dataset_draft.get_locally_valid() assert runtime.warning_capture.runtime_warnings == () @@ -191,7 +191,7 @@ def test_dataset_local_mutations_flow_through_existing_models( assert "Cyclopentane" in draft.selected_fluid_values() assert draft.output_selected("csv") is False assert pressure.get_unit() == "kPa" - assert desktop.dataset_config_controller.get_locally_valid() + assert desktop.configuration_controller.get_locally_valid() assert runtime.warning_capture.runtime_warnings == () @@ -563,8 +563,8 @@ def test_worker_authoritative_import_opens_dataset_page( _process_events() assert desktop.dataset_draft.get_mode_name() == "saturation_table" - assert desktop.dataset_config_controller.document is not None - assert desktop.dataset_config_controller.document.imported + assert desktop.configuration_controller.document is not None + assert desktop.configuration_controller.document.imported assert root.property("currentPage") == "dataset" assert runtime.warning_capture.runtime_warnings == () @@ -602,8 +602,9 @@ def test_qml_uses_child_drafts_only_for_local_edits() -> None: assert "datasetDraft.applyModeChange" not in dataset_source assert "datasetDraft.setCoordinate" not in dataset_source - assert "datasetConfigController.newDataset" not in workspace_source - assert "datasetConfigController.importDataset" not in workspace_source + assert "configurationController.newDataset" not in workspace_source + assert "configurationController.newSweep" not in workspace_source + assert "configurationController.importDataset" not in workspace_source assert "datasetDraft.addFluid" not in dataset_source assert "datasetDraft.addProperty" not in dataset_source assert "datasetDraft.removeFluid" not in dataset_source @@ -613,7 +614,8 @@ def test_qml_uses_child_drafts_only_for_local_edits() -> None: assert "signal modeChangeRequested" in dataset_source assert "signal coordinateChangeRequested" in dataset_source assert "signal newDatasetRequested" in workspace_source - assert "signal importDatasetRequested" in workspace_source + assert "signal newSweepRequested" in workspace_source + assert "signal importConfigurationRequested" in workspace_source assert "unitChangeRequested" in sampler_source assert re.search(r"\bdraft\.unit\s*=(?!=)", sampler_source) is None assert re.search(r"\bdraft\.kind\s*=(?!=)", sampler_source) is None diff --git a/tests/test_app_qml_inspection.py b/tests/test_app_qml_inspection.py index 2873930..8f8b6bd 100644 --- a/tests/test_app_qml_inspection.py +++ b/tests/test_app_qml_inspection.py @@ -13,6 +13,7 @@ from PySide6.QtQuick import QQuickItem from PySide6.QtWidgets import QApplication +from carnopy.app.inspection_controller import InspectionController from carnopy.app.qml_runtime import QmlApplicationRuntime, create_qml_runtime from carnopy.app.workspace import initialize_workspace @@ -36,6 +37,144 @@ def _write_dataset(path: Path, rows: int = 150) -> None: path.write_text(header + body, encoding="utf-8") +def _accept_preparation_eligible_inspection( + controller: InspectionController, + source: Path, +) -> None: + revision = "a" * 64 + resolved = source.resolve() + descriptor = { + "source_path": str(resolved), + "source_kind": "dataset_run", + "inspection_revision": revision, + "controls": {}, + "tables": [], + } + profile = { + "profile_schema_version": 1, + "source_path": str(resolved), + "source_kind": "dataset_run", + "inspection_revision": revision, + "source_identity": {"source_kind": "dataset_run"}, + "completion": { + "status": "completed", + "partial": False, + "included_child_models": [], + "missing_child_models": [], + }, + "available_models": ["heos"], + "declared_models": [], + "reference_model": "heos", + "numeric_candidates": [], + "target_candidates": [], + "categorical_candidates": [], + "auxiliary_candidates": [], + "observed_category_values": {}, + "derived_features": [], + "model_holdout": { + "available": False, + "reason": "Model holdout scenarios require a model-sweep source.", + }, + "reference_context": { + "compatible": True, + "compatible_context": { + "reference_state_policy": "coolprop_DEF", + "backend": "coolprop", + "backend_model": "heos", + }, + "contexts": [], + "reason_code": "", + "reason": "", + }, + } + controller._clear_inspection(source=resolved, state="loading") + controller._accept_inspection_payload( + { + "source": str(resolved), + "source_kind": "dataset", + "revision": revision, + "summary": {}, + "tables": [], + "arrays": [], + "plot_context": None, + "preparation_eligible": True, + "preparation_ineligible_reason": "", + "preparation_source_descriptor": descriptor, + "preparation_profile": profile, + } + ) + + +def _accept_preparation_audit_inspection( + controller: InspectionController, + source: Path, + *, + audit_recorded: bool = True, +) -> None: + revision = "b" * 64 + resolved = source.resolve() + summary: dict[str, object] = { + "source": str(resolved), + "status": "completed", + "row_counts": {"source": 2, "eligible": 2, "excluded": 0}, + "quality": { + "errors": (["quality flags artifact is unavailable"] if audit_recorded else []), + "summary": ( + { + "status": "completed", + "row_counts": {"eligible": 2, "excluded": 0}, + "matrix_diagnostics": {"status": "not_requested", "fits": []}, + "baseline_diagnostics": {"status": "not_requested", "fits": []}, + } + if audit_recorded + else {} + ), + }, + } + payload: dict[str, object] = { + "source": str(resolved), + "source_kind": "preparation", + "revision": revision, + "summary": summary, + "tables": [], + "arrays": [], + "plot_context": None, + "preparation_eligible": False, + "preparation_ineligible_reason": "", + "preparation_source_descriptor": None, + "preparation_profile": None, + } + if audit_recorded: + summary["scenarios"] = { + "status": "completed", + "scenario_count": 1, + "partition_count": 2, + "scenarios": [ + { + "name": "shuffle", + "kind": "shuffle", + "partition_counts": {"test": 1, "train": 1}, + "transformations": [], + } + ], + } + payload["preparation_audit"] = { + "audit_schema_version": 1, + "scenario_details": [ + { + "name": "shuffle", + "state_leakage": { + "identity_column": "source_state_hash", + "duplicate_state_group_count": 0, + "cross_partition_group_count": 0, + }, + } + ], + } + controller._clear_inspection(source=resolved, state="loading") + controller._accept_inspection_payload(payload) + + @pytest.fixture def runtime( tmp_path: Path, @@ -90,6 +229,16 @@ def _visible_item(root: QObject, object_name: str) -> QQuickItem: return matches[0] +def _visual_item(root: QQuickItem, object_name: str) -> QQuickItem: + pending = [root] + while pending: + candidate = pending.pop() + if candidate.objectName() == object_name: + return candidate + pending.extend(candidate.childItems()) + raise AssertionError(f"missing visual item: {object_name}") + + def test_inspect_page_uses_bounded_worker_preview_and_focus_mode( runtime: QmlApplicationRuntime, ) -> None: @@ -192,3 +341,117 @@ def test_inspection_facade_normalizes_a_qml_file_url( assert controller.get_source_path() == str(source) assert runtime.warning_capture.runtime_warnings == () + + +def test_inspect_page_binds_and_explicitly_clears_a_preparation_source( + runtime: QmlApplicationRuntime, +) -> None: + root = runtime.engine.rootObjects()[0] + inspection = runtime.controller.inspection_controller + preparation = runtime.controller.preparation_workflow_controller + source = Path(str(inspection.workspace_sources_model.get(0)["path"])) + assert root.setProperty("currentPage", "inspect") + _accept_preparation_eligible_inspection(inspection, source) + _process_events() + + bind_button = _visible_item(root, "preparationBindSourceButton") + assert inspection.get_preparation_eligible() + assert bind_button.property("text") == "Use for ML Preparation" + assert bind_button.property("enabled") is True + assert QMetaObject.invokeMethod(bind_button, "click") + _process_events() + + assert preparation.get_has_bound_source() + assert preparation.get_bound_source_path() == str(source.resolve()) + assert preparation.get_inspected_source_matches_binding() + assert bind_button.property("text") == "Used for ML Preparation" + assert bind_button.property("enabled") is False + + clear_button = _visible_item(root, "preparationClearSourceButton") + assert QMetaObject.invokeMethod(clear_button, "click") + _process_events() + assert not preparation.get_has_bound_source() + + assert QMetaObject.invokeMethod(bind_button, "click") + _process_events() + assert preparation.get_has_bound_source() + + runtime.controller.preparationSourceClearConfirmationRequested.emit() + _process_events() + clear_dialog = root.findChild(QObject, "preparationSourceClearDialog") + assert clear_dialog is not None + assert clear_dialog.property("opened") is True + clear_dialog.accept() + _process_events() + + assert not preparation.get_has_bound_source() + assert runtime.warning_capture.runtime_warnings == () + + +def test_inspect_page_presents_the_accepted_preparation_audit_and_issues( + runtime: QmlApplicationRuntime, +) -> None: + root = runtime.engine.rootObjects()[0] + inspection = runtime.controller.inspection_controller + source = runtime.controller.workspace_controller.workspace.outputs / "prepared-audit" + source.mkdir() + assert root.setProperty("width", 1440) + assert root.setProperty("height", 1000) + assert root.setProperty("currentPage", "inspect") + _accept_preparation_audit_inspection(inspection, source) + _process_events() + + page = root.findChild(QObject, "inspectPage") + assert page is not None + audit_tab = _visible_item(root, "inspectionPreparationAuditTab") + assert audit_tab.property("text") == "Preparation Audit" + assert QMetaObject.invokeMethod(audit_tab, "click") + _process_events() + + assert page.property("selectedTab") == 4 + audit_view = _visible_item(root, "inspectionPreparationAuditView") + assert audit_view.property("audit") is inspection.preparation_audit_model + assert inspection.preparation_quality_errors_model.get_count() == 1 + assert root.findChild(QObject, "preparationAuditScenariosList").property("count") == 1 + assert _visible_item(root, "inspectionPreparationAuditIssuesCard").isVisible() + audit_pane = _visible_item(root, "inspectionPreparationAuditPane") + issue = _visual_item(audit_pane, "inspectionPreparationAuditIssue-0") + assert issue.isVisible() + assert issue.property("text") == "quality flags artifact is unavailable" + + assert root.setProperty("width", 768) + _process_events() + overview_grid = root.findChild(QObject, "preparationAuditOverviewGrid") + assert overview_grid is not None + assert overview_grid.property("maximumColumns") == 1 + + dataset_source = Path(str(inspection.workspace_sources_model.get(0)["path"])) + _accept_preparation_eligible_inspection(inspection, dataset_source) + _process_events() + + assert page.property("selectedTab") == 0 + assert not root.findChild(QQuickItem, "inspectionPreparationAuditTab").isVisible() + assert runtime.warning_capture.runtime_warnings == () + + +def test_inspect_page_keeps_legacy_preparation_audit_unavailability_visible( + runtime: QmlApplicationRuntime, +) -> None: + root = runtime.engine.rootObjects()[0] + inspection = runtime.controller.inspection_controller + source = runtime.controller.workspace_controller.workspace.outputs / "legacy-preparation" + source.mkdir() + assert root.setProperty("currentPage", "inspect") + _accept_preparation_audit_inspection(inspection, source, audit_recorded=False) + _process_events() + + assert not inspection.preparation_audit_model.get_available() + audit_tab = _visible_item(root, "inspectionPreparationAuditTab") + assert QMetaObject.invokeMethod(audit_tab, "click") + _process_events() + + assert _visible_item(root, "preparationAuditStatus").property("label") == "Unavailable" + issues_card = root.findChild(QQuickItem, "inspectionPreparationAuditIssuesCard") + assert issues_card is not None + assert not issues_card.isVisible() + assert runtime.warning_capture.runtime_warnings == () diff --git a/tests/test_app_qml_preparation.py b/tests/test_app_qml_preparation.py new file mode 100644 index 0000000..2ed7bf9 --- /dev/null +++ b/tests/test_app_qml_preparation.py @@ -0,0 +1,749 @@ +from __future__ import annotations + +import os +from collections.abc import Iterator +from pathlib import Path +from typing import Any, cast + +import pytest +import yaml + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from PySide6.QtCore import ( + QCoreApplication, + QEventLoop, + QMetaObject, + QObject, + QPointF, + QSettings, + QTimer, +) +from PySide6.QtQml import QQmlComponent +from PySide6.QtQuick import QQuickItem, QQuickWindow +from PySide6.QtWidgets import QApplication + +from carnopy.app.config_document import new_document +from carnopy.app.qml_resources import MANDATORY_QML_FILES +from carnopy.app.qml_runtime import QmlApplicationRuntime, create_qml_runtime +from carnopy.app.scenario_draft import ScenarioDraft +from carnopy.app.workspace import initialize_workspace +from carnopy.templates import template_text + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def application() -> QApplication: + existing = QApplication.instance() + return existing if isinstance(existing, QApplication) else QApplication([]) + + +@pytest.fixture +def runtime(tmp_path: Path, application: QApplication) -> Iterator[QmlApplicationRuntime]: + del application + workspace = initialize_workspace(tmp_path / "workspace") + created = create_qml_runtime( + settings=QSettings(str(tmp_path / "settings.ini"), QSettings.Format.IniFormat), + initial_workspace=workspace.root, + application_arguments=[], + ) + _wait_for_idle(created) + yield created + _wait_for_idle(created) + assert created.close() + assert created.warning_capture.runtime_warnings == () + + +@pytest.fixture +def scenario_editor(runtime: QmlApplicationRuntime) -> Iterator[QQuickItem]: + desktop = runtime.controller + draft = ScenarioDraft( + field_choices=("temperature", "pressure", "fluid", "phase"), + parent=desktop, + ) + + root = runtime.engine.rootObjects()[0] + assert isinstance(root, QQuickWindow) + root.setWidth(1200) + root.setHeight(1200) + component = QQmlComponent(runtime.engine) + component.loadFromModule("Carnopy", "PreparationScenarioEditor") + assert component.status() == QQmlComponent.Status.Ready, _component_errors(component) + created = component.createWithInitialProperties( + { + "desktopController": desktop, + "dialogsEnabled": False, + "draft": draft, + "locked": False, + } + ) + assert isinstance(created, QQuickItem), _component_errors(component) + created.setParent(root) + created.setParentItem(root.contentItem()) + created.setWidth(900) + created.setZ(1000) + _process_events() + yield created + + +@pytest.fixture +def preparation_page(runtime: QmlApplicationRuntime) -> Iterator[QQuickItem]: + desktop = runtime.controller + payload = yaml.safe_load(template_text(cast(Any, "preparation"))) + assert isinstance(payload, dict) + assert desktop.configuration_controller.open_document(new_document(payload)) + + root = runtime.engine.rootObjects()[0] + assert isinstance(root, QQuickWindow) + root.setWidth(1440) + root.setHeight(1200) + component = QQmlComponent(runtime.engine) + component.loadFromModule("Carnopy", "PreparationPage") + assert component.status() == QQmlComponent.Status.Ready, _component_errors(component) + created = component.createWithInitialProperties( + { + "configController": desktop.configuration_controller, + "desktopController": desktop, + "dialogsEnabled": False, + "expectedColumns": 3, + "inspectionController": desktop.inspection_controller, + "preparationDraft": desktop.configuration_controller.preparation_draft, + "workflowController": desktop.preparation_workflow_controller, + } + ) + assert isinstance(created, QQuickItem), _component_errors(component) + created.setObjectName("directPreparationPage") + created.setParent(root) + created.setParentItem(root.contentItem()) + created.setWidth(root.width()) + created.setHeight(root.height()) + created.setZ(1000) + _process_events() + yield created + + +def _component_errors(component: QQmlComponent) -> str: + return "\n".join(error.toString() for error in component.errors()) + + +def _wait_for_idle(runtime: QmlApplicationRuntime) -> None: + if not runtime.controller.request_coordinator.is_busy: + runtime.application.processEvents() + return + loop = QEventLoop() + runtime.controller.request_coordinator.busy_changed.connect( + lambda busy: None if busy else loop.quit() + ) + QTimer.singleShot(15_000, loop.quit) + loop.exec() + runtime.application.processEvents() + assert not runtime.controller.request_coordinator.is_busy + + +def _process_events() -> None: + application = QCoreApplication.instance() + assert application is not None + for _ in range(6): + application.processEvents() + + +def _item(root: QQuickItem, object_name: str) -> QQuickItem: + pending = [root] + while pending: + candidate = pending.pop() + if candidate.objectName() == object_name: + return candidate + pending.extend(candidate.childItems()) + raise AssertionError(f"missing visual item: {object_name}") + + +def _visual_names(root: QQuickItem) -> set[str]: + names: set[str] = set() + pending = [root] + while pending: + candidate = pending.pop() + if candidate.objectName(): + names.add(candidate.objectName()) + pending.extend(candidate.childItems()) + return names + + +def _visible_item(root: QQuickWindow, object_name: str) -> QQuickItem: + pending = [root.contentItem()] + matches: list[QQuickItem] = [] + while pending: + candidate = pending.pop() + if candidate.objectName() == object_name and candidate.isVisible(): + matches.append(candidate) + pending.extend(candidate.childItems()) + assert len(matches) == 1 + return matches[0] + + +def _accept_preparation_eligible_inspection( + runtime: QmlApplicationRuntime, + source: Path, +) -> None: + revision = "a" * 64 + resolved = source.resolve() + descriptor = { + "source_path": str(resolved), + "source_kind": "dataset_run", + "inspection_revision": revision, + "controls": {}, + "tables": [], + } + profile = { + "profile_schema_version": 1, + "source_path": str(resolved), + "source_kind": "dataset_run", + "inspection_revision": revision, + "source_identity": {"source_kind": "dataset_run"}, + "completion": { + "status": "completed", + "partial": False, + "included_child_models": [], + "missing_child_models": [], + }, + "available_models": ["heos"], + "declared_models": [], + "reference_model": "heos", + "numeric_candidates": [], + "target_candidates": [], + "categorical_candidates": [], + "auxiliary_candidates": [], + "observed_category_values": {}, + "derived_features": [], + "model_holdout": { + "available": False, + "reason": "Model holdout scenarios require a model-sweep source.", + }, + "reference_context": { + "compatible": True, + "compatible_context": { + "reference_state_policy": "coolprop_DEF", + "backend": "coolprop", + "backend_model": "heos", + }, + "contexts": [], + "reason_code": "", + "reason": "", + }, + } + inspection = runtime.controller.inspection_controller + inspection._clear_inspection(source=resolved, state="loading") + inspection._accept_inspection_payload( + { + "source": str(resolved), + "source_kind": "dataset", + "revision": revision, + "summary": {}, + "tables": [], + "arrays": [], + "plot_context": None, + "preparation_eligible": True, + "preparation_ineligible_reason": "", + "preparation_source_descriptor": descriptor, + "preparation_profile": profile, + } + ) + + +def test_shell_requires_a_bound_source_then_enables_the_preparation_surface( + runtime: QmlApplicationRuntime, + tmp_path: Path, +) -> None: + desktop = runtime.controller + controller = desktop.configuration_controller + root = runtime.engine.rootObjects()[0] + assert isinstance(root, QQuickWindow) + root.setWidth(1440) + root.setHeight(1200) + _process_events() + + workspace_page = root.findChild(QObject, "workspacePage") + preparation_button = _visible_item(root, "newPreparationButton") + preparation_navigation = _visible_item(root, "nav-preparation") + assert workspace_page is not None + assert preparation_button.property("text") == "Choose source in Inspect" + assert preparation_button.property("enabled") is True + assert preparation_navigation.property("enabled") is True + + assert QMetaObject.invokeMethod(preparation_button, "click") + _process_events() + assert root.property("currentPage") == "inspect" + assert not controller.get_has_document() + + source = tmp_path / "workspace" / "outputs" / "eligible-source" + source.mkdir() + _accept_preparation_eligible_inspection(runtime, source) + _process_events() + bind_button = _visible_item(root, "preparationBindSourceButton") + assert bind_button.width() > 36 + assert bind_button.property("text") == "Use for ML Preparation" + assert QMetaObject.invokeMethod(bind_button, "click") + _process_events() + assert desktop.preparation_workflow_controller.get_has_bound_source() + assert not bind_button.isVisible() + assert _visible_item(root, "preparationClearSourceButton").width() > 36 + assert _visible_item(root, "preparationContinueButton").width() > 36 + + assert desktop.request_new_dataset("property_table") + assert root.setProperty("currentPage", "workspace") + _process_events() + preparation_button = _visible_item(root, "newPreparationButton") + assert preparation_button.property("text") == "New ML Preparation" + assert QMetaObject.invokeMethod(preparation_button, "click") + _process_events() + + discard_dialog = root.findChild(QObject, "configurationDiscardDialog") + assert discard_dialog is not None + assert discard_dialog.property("opened") is True + assert controller.get_document_kind() == "dataset" + discard_dialog.accept() + _process_events() + + page = root.findChild(QObject, "preparationPage") + command_bar = root.findChild(QObject, "documentCommandBar") + inspector = _visible_item(root, "preparationWorkflowContextInspector") + assert page is not None + assert command_bar is not None + assert controller.get_document_kind() == "preparation" + assert root.property("currentPage") == "preparation" + assert page.property("visible") is True + assert page.property("documentActive") is True + assert page.property("preparationDraft") is controller.preparation_draft + assert page.property("workflowController") is desktop.preparation_workflow_controller + assert command_bar.property("pageTitle") == "ML Preparation" + assert command_bar.property("statusLabel") == "Unsaved Preparation" + assert inspector.property("visible") is True + + change_source = _visible_item(root, "preparationChangeSource") + assert QMetaObject.invokeMethod(change_source, "click") + _process_events() + assert root.property("currentPage") == "inspect" + assert desktop.preparation_workflow_controller.get_bound_source_path() == str(source.resolve()) + assert runtime.warning_capture.runtime_warnings == () + + +def test_shell_queues_mutations_from_the_visible_preparation_page( + runtime: QmlApplicationRuntime, + tmp_path: Path, +) -> None: + desktop = runtime.controller + controller = desktop.configuration_controller + root = runtime.engine.rootObjects()[0] + assert isinstance(root, QQuickWindow) + root.setWidth(1440) + root.setHeight(1200) + + source = tmp_path / "workspace" / "outputs" / "eligible-source" + source.mkdir() + _accept_preparation_eligible_inspection(runtime, source) + assert root.setProperty("currentPage", "inspect") + _process_events() + assert QMetaObject.invokeMethod(_visible_item(root, "preparationBindSourceButton"), "click") + _process_events() + + continue_button = _visible_item(root, "preparationContinueButton") + assert QMetaObject.invokeMethod(continue_button, "click") + _process_events() + assert root.property("currentPage") == "preparation" + assert not controller.get_has_document() + + create_button = _visible_item(root, "preparationCreateDocumentButton") + assert QMetaObject.invokeMethod(create_button, "click") + _process_events() + assert controller.get_document_kind() == "preparation" + + page = root.findChild(QQuickItem, "preparationPage") + assert page is not None + add_button = _item(page, "preparationAddScenario") + assert QMetaObject.invokeMethod(add_button, "click") + _process_events() + active = controller.preparation_draft.get_active_scenario_draft() + assert isinstance(active, ScenarioDraft) + + editor = _item(page, "preparationScenarioEditor") + name_field = _item(editor, "preparationScenarioName") + assert name_field.setProperty("text", "queued-scenario") + assert QMetaObject.invokeMethod(name_field, "editingFinished") + _process_events() + assert active.get_name() == "queued-scenario" + + commit_button = _item(editor, "preparationScenarioCommitButton") + assert QMetaObject.invokeMethod(commit_button, "click") + _process_events() + assert not controller.preparation_draft.get_has_active_scenario_edit() + assert controller.preparation_draft.scenarios_model.rowCount() == 1 + + edit_button = _item(page, "preparationScenarioEdit-0") + assert edit_button.width() > 36 + assert QMetaObject.invokeMethod(edit_button, "click") + _process_events() + assert controller.preparation_draft.get_has_active_scenario_edit() + + editor = _item(page, "preparationScenarioEditor") + assert QMetaObject.invokeMethod(_item(editor, "preparationScenarioCancelButton"), "click") + _process_events() + assert not controller.preparation_draft.get_has_active_scenario_edit() + + matrix_check = _item(page, "preparationMatrixDiagnostics") + matrix_settings = _item(page, "preparationMatrixSettingsGrid") + assert not controller.preparation_draft.get_matrix_enabled() + assert not matrix_settings.isVisible() + assert QMetaObject.invokeMethod(matrix_check, "click") + _process_events() + assert controller.preparation_draft.get_matrix_enabled() + assert matrix_settings.isVisible() + assert QMetaObject.invokeMethod(matrix_check, "click") + _process_events() + assert not controller.preparation_draft.get_matrix_enabled() + assert not matrix_settings.isVisible() + assert runtime.warning_capture.runtime_warnings == () + + +def test_scenario_editor_binds_the_complete_temporary_surface( + runtime: QmlApplicationRuntime, + scenario_editor: QQuickItem, +) -> None: + active = scenario_editor.property("draft") + assert isinstance(active, ScenarioDraft) + + expected = { + "preparationScenarioEditor", + "preparationScenarioEditorIssue", + "preparationScenarioBasicsGrid", + "preparationScenarioName", + "preparationScenarioKind", + "preparationScenarioSeed", + "preparationScenarioField", + "preparationScenarioRemainder", + "preparationScenarioPartitionsCard", + "preparationScenarioPartitions", + "preparationScenarioHoldoutsCard", + "preparationScenarioHoldouts", + "preparationScenarioStratificationCard", + "preparationScenarioStrataFields", + "preparationScenarioNumericBins", + "preparationScenarioTransformationsCard", + "preparationScenarioTransformations", + "preparationScenarioCancelButton", + "preparationScenarioCommitButton", + } + assert expected <= _visual_names(scenario_editor) + assert scenario_editor.property("draft") is active + assert _item(scenario_editor, "preparationScenarioCommitButton").property("enabled") + assert not _item(scenario_editor, "preparationScenarioPartitionsCard").isVisible() + assert not _item(scenario_editor, "preparationScenarioHoldoutsCard").isVisible() + assert not _item(scenario_editor, "preparationScenarioStratificationCard").isVisible() + assert runtime.warning_capture.runtime_warnings == () + + +def test_scenario_editor_projects_all_kind_specific_sections_without_warnings( + runtime: QmlApplicationRuntime, + scenario_editor: QQuickItem, +) -> None: + active = scenario_editor.property("draft") + assert isinstance(active, ScenarioDraft) + cases = ( + ("shuffle", True, False, False), + ("stratified_hash", True, False, True), + ("coordinate_block", False, True, False), + ("range_holdout", False, True, False), + ("leave_fluid_out", False, True, False), + ("phase_holdout", False, True, False), + ("model_holdout", False, True, False), + ("unsplit", False, False, False), + ) + for kind, partitions, holdouts, strata in cases: + active.apply_kind_change(kind, True) + _process_events() + assert active.get_kind() == kind + assert _item(scenario_editor, "preparationScenarioPartitionsCard").isVisible() is partitions + assert _item(scenario_editor, "preparationScenarioHoldoutsCard").isVisible() is holdouts + assert _item(scenario_editor, "preparationScenarioStratificationCard").isVisible() is strata + + assert runtime.warning_capture.runtime_warnings == () + + +def test_scenario_editor_nested_models_focus_and_responsive_layout_are_authoritative( + runtime: QmlApplicationRuntime, + scenario_editor: QQuickItem, +) -> None: + active = scenario_editor.property("draft") + assert isinstance(active, ScenarioDraft) + active.apply_kind_change("stratified_hash", True) + active.set_strata_categorical("fluid, phase") + active.set_numeric_bins("temperature", "250, 300") + active.add_transformation("pressure", "log10, standard") + _process_events() + + assert active.get_strata_categorical_text() == "fluid, phase" + assert active.numeric_bin_rows.get_count() == 1 + assert active.transformation_rows.get_count() == 1 + assert _item(scenario_editor, "preparationScenarioNumericBins").property("count") == 1 + assert _item(scenario_editor, "preparationScenarioTransformations").property("count") == 1 + + scenario_editor.setProperty("attentionField", "preparation.scenario.active.seed") + scenario_editor.setProperty("attentionSerial", 1) + _process_events() + assert _item(scenario_editor, "preparationScenarioSeed").property("activeFocus") is True + + assert _item(scenario_editor, "preparationScenarioBasicsGrid").property("columns") == 2 + scenario_editor.setWidth(600) + _process_events() + assert _item(scenario_editor, "preparationScenarioBasicsGrid").property("columns") == 1 + assert runtime.warning_capture.runtime_warnings == () + + +def test_preparation_scenario_qml_resource_and_controller_boundary_are_explicit() -> None: + qml_root = ROOT / "src/carnopy/app/qml/Carnopy" + source = (qml_root / "components/PreparationScenarioEditor.qml").read_text(encoding="utf-8") + qmldir = (qml_root / "qmldir").read_text(encoding="utf-8") + + assert "PreparationScenarioEditor 1.0 components/PreparationScenarioEditor.qml" in qmldir + assert "qml/Carnopy/components/PreparationScenarioEditor.qml" in MANDATORY_QML_FILES + assert "scenarioFieldChangeRequested" in source + assert "scenarioTransformationAddRequested" in source + assert "desktopController.requestPreparationScenario" not in source + assert 'Accessible.name: qsTr("Scenario name")' in source + assert 'Accessible.name: qsTr("Scenario partitions")' in source + assert 'Accessible.name: qsTr("Scenario transformations")' in source + assert ".setName(" not in source + assert ".setPartition(" not in source + assert ".addTransformation(" not in source + assert "yaml" not in source.casefold() + + +def test_preparation_page_binds_the_complete_authoritative_editor( + runtime: QmlApplicationRuntime, + preparation_page: QQuickItem, +) -> None: + desktop = runtime.controller + draft = desktop.configuration_controller.preparation_draft + + assert preparation_page.property("configController") is desktop.configuration_controller + assert preparation_page.property("desktopController") is desktop + assert preparation_page.property("inspectionController") is desktop.inspection_controller + assert preparation_page.property("preparationDraft") is draft + assert ( + preparation_page.property("workflowController") is desktop.preparation_workflow_controller + ) + assert preparation_page.property("documentActive") is True + assert preparation_page.property("locked") is False + + expected = { + "preparationPageFlickable", + "preparationLocalState", + "preparationBoundSourceCard", + "preparationBoundSourceState", + "preparationRolesGrid", + "preparationNumericFeaturesCard", + "preparationDerivedFeaturesCard", + "preparationTargetsCard", + "preparationAuxiliaryCard", + "preparationCategoricalCard", + "preparationScenariosCard", + "preparationAllowPartialSweep", + "preparationAddScenario", + "preparationScenarioList", + "preparationOutputQualityGrid", + "preparationOutputsCard", + "preparationArrayOutputs", + "preparationQualityCard", + "preparationMatrixDiagnostics", + "preparationBaselineDiagnostics", + "preparationBaselineDependencyGuidance", + "preparationWorkflowRunPanel", + "preparationPlanButton", + "preparationExecuteButton", + } + names = _visual_names(preparation_page) + assert expected <= names + assert _item(preparation_page, "preparationRolesGrid").property("maximumColumns") == 3 + assert _item(preparation_page, "preparationOutputQualityGrid").property("maximumColumns") == 2 + assert _item(preparation_page, "preparationNumeric-temperature").property("checked") + assert _item(preparation_page, "preparationTarget-specific_enthalpy").property("checked") + assert _item(preparation_page, "preparationCategorical-phase").property("checked") + assert not _item(preparation_page, "preparationPlanButton").property("enabled") + assert not _item(preparation_page, "preparationExecuteButton").property("enabled") + guidance = _item(preparation_page, "preparationBaselineDependencyGuidance").property("text") + assert bool(guidance) is (not draft.get_baseline_available()) + if guidance: + assert "carnopy[analysis]" in guidance + assert runtime.warning_capture.runtime_warnings == () + + +def test_preparation_quality_fields_remain_labeled_and_cards_stay_top_aligned( + runtime: QmlApplicationRuntime, + preparation_page: QQuickItem, +) -> None: + draft = runtime.controller.configuration_controller.preparation_draft + grid = _item(preparation_page, "preparationOutputQualityGrid") + outputs = _item(preparation_page, "preparationOutputsCard") + quality = _item(preparation_page, "preparationQualityCard") + assert grid.property("columnCount") == 2 + + initial_tops = ( + outputs.mapToItem(grid, QPointF(0, 0)).y(), + quality.mapToItem(grid, QPointF(0, 0)).y(), + ) + assert initial_tops[0] == pytest.approx(initial_tops[1], abs=1) + + draft.apply_capabilities( + { + "workflows": { + "preparation": { + "baseline_diagnostics": {"available": True, "guidance": ""}, + "safetensors": {"available": False, "guidance": ""}, + } + } + } + ) + assert draft.set_matrix_enabled(True) + assert draft.set_baseline_enabled(True) + _process_events() + + expected_labels = { + "preparationCorrelationThresholdLabel": "Correlation threshold (absolute r)", + "preparationNearConstantSpreadLabel": "Near-constant relative spread", + "preparationBaselineSeedLabel": "Baseline random seed", + "preparationRidgeAlphaLabel": "Ridge alpha", + "preparationHistogramIterationsLabel": "Histogram maximum iterations", + } + for object_name, text in expected_labels.items(): + label = _item(preparation_page, object_name) + assert label.isVisible() + assert label.property("text") == text + + expanded_tops = ( + outputs.mapToItem(grid, QPointF(0, 0)).y(), + quality.mapToItem(grid, QPointF(0, 0)).y(), + ) + assert expanded_tops[0] == pytest.approx(expanded_tops[1], abs=1) + assert runtime.warning_capture.runtime_warnings == () + + +def test_preparation_page_keeps_app_only_optional_features_explicitly_unavailable( + runtime: QmlApplicationRuntime, + preparation_page: QQuickItem, +) -> None: + desktop = runtime.controller + draft = desktop.configuration_controller.preparation_draft + draft.apply_capabilities( + { + "preparation": { + "safetensors": { + "available": False, + "guidance": 'Install the optional dependency with: pip install "carnopy[ml]"', + }, + "baseline_diagnostics": { + "available": False, + "guidance": ( + 'Install the optional dependency with: pip install "carnopy[analysis]"' + ), + }, + } + } + ) + desktop.request_preparation_boolean_field("array_outputs", True) + assert draft.get_array_outputs_enabled() + _process_events() + + safetensors = _item(preparation_page, "preparationArrayFormat-safetensors") + baseline = _item(preparation_page, "preparationBaselineDiagnostics") + guidance = _item(preparation_page, "preparationBaselineDependencyGuidance") + assert safetensors.property("enabled") is False + safetensors_choice = next( + item for item in draft.array_format_choices.items if item.value == "safetensors" + ) + assert "carnopy[ml]" in safetensors_choice.issue + assert baseline.property("enabled") is False + assert "carnopy[analysis]" in guidance.property("text") + assert runtime.warning_capture.runtime_warnings == () + + +def test_preparation_page_restores_one_python_owned_scenario_editor( + runtime: QmlApplicationRuntime, + preparation_page: QQuickItem, +) -> None: + desktop = runtime.controller + draft = desktop.configuration_controller.preparation_draft + + assert desktop.request_preparation_add_scenario() + _process_events() + assert draft.get_has_active_scenario_edit() + editor = _item(preparation_page, "preparationScenarioEditor") + active = draft.get_active_scenario_draft() + assert isinstance(active, ScenarioDraft) + assert editor.property("draft") is active + desktop.request_preparation_scenario_field_change(active, "name", "complete-source") + _process_events() + assert _item(editor, "preparationScenarioCommitButton").property("enabled") + assert desktop.request_preparation_commit_scenario() + _process_events() + + assert not draft.get_has_active_scenario_edit() + assert draft.scenarios_model.rowCount() == 1 + assert draft.scenarios_model.rows()[0]["name"] == "complete-source" + assert _item(preparation_page, "preparationScenarioList").property("count") == 1 + assert runtime.warning_capture.runtime_warnings == () + + +def test_preparation_page_focus_and_responsive_state_remain_warning_free( + runtime: QmlApplicationRuntime, + preparation_page: QQuickItem, +) -> None: + desktop = runtime.controller + draft = desktop.configuration_controller.preparation_draft + + preparation_page.setProperty("attentionField", "preparation.outputs") + preparation_page.setProperty("attentionSerial", 1) + _process_events() + assert _item(preparation_page, "preparationOutputsCard").property("activeFocus") is True + + assert desktop.request_preparation_add_scenario() + _process_events() + active = draft.get_active_scenario_draft() + assert isinstance(active, ScenarioDraft) + preparation_page.setProperty("attentionField", "preparation.scenario.active.name") + preparation_page.setProperty("attentionSerial", 2) + _process_events() + assert _item(preparation_page, "preparationScenarioName").property("activeFocus") is True + + preparation_page.setWidth(720) + preparation_page.setProperty("expectedColumns", 1) + _process_events() + assert _item(preparation_page, "preparationRolesGrid").property("maximumColumns") == 1 + assert _item(preparation_page, "preparationOutputQualityGrid").property("maximumColumns") == 1 + assert desktop.request_preparation_cancel_scenario() + assert runtime.warning_capture.runtime_warnings == () + + +def test_preparation_page_qml_resource_and_controller_boundary_are_explicit() -> None: + qml_root = ROOT / "src/carnopy/app/qml/Carnopy" + source = (qml_root / "pages/PreparationPage.qml").read_text(encoding="utf-8") + qmldir = (qml_root / "qmldir").read_text(encoding="utf-8") + + assert "PreparationPage 1.0 pages/PreparationPage.qml" in qmldir + assert "qml/Carnopy/pages/PreparationPage.qml" in MANDATORY_QML_FILES + assert "booleanFieldRequested" in source + assert "roleSelectionRequested" in source + assert "requestWorkflow" in source + assert 'Accessible.name: qsTr("Committed Preparation scenarios")' in source + assert 'Accessible.name: qsTr("Enable matrix diagnostics")' in source + assert ".setRoleSelected(" not in source + assert ".beginAddScenario(" not in source + assert ".commitScenario(" not in source + assert "desktopController.requestPreparation" not in source + assert "desktopController.requestBindInspectedPreparationSource" not in source + assert "desktopController.requestClearPreparationSource" not in source + assert "desktopController.requestPreparationScenario" not in source + assert "PreparationAuditView" not in source + assert "TextArea" not in source + main_source = (qml_root / "Main.qml").read_text(encoding="utf-8") + assert 'currentPage === "preparation"' in main_source + assert "PreparationPage" in main_source diff --git a/tests/test_app_qml_preparation_audit.py b/tests/test_app_qml_preparation_audit.py new file mode 100644 index 0000000..0fd0ba9 --- /dev/null +++ b/tests/test_app_qml_preparation_audit.py @@ -0,0 +1,517 @@ +from __future__ import annotations + +import os +from collections.abc import Iterator +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from PySide6.QtCore import QCoreApplication, QEventLoop, QSettings, QTimer +from PySide6.QtQml import QQmlComponent +from PySide6.QtQuick import QQuickItem, QQuickWindow +from PySide6.QtWidgets import QApplication + +from carnopy.app.preparation_audit import ( + BASELINE_CHECK_ROLES, + BASELINE_FAILURE_ROLES, + BASELINE_METRIC_ROLES, + CORRELATED_PAIR_ROLES, + DUPLICATE_STATE_ROLES, + FEATURE_TARGET_CORRELATION_ROLES, + GRID_GROUP_ROLES, + GRID_PHASE_ROLES, + GRID_SPACING_ROLES, + LEAKAGE_ROLES, + MATRIX_CHECK_ROLES, + MATRIX_FEATURE_FLAG_ROLES, + PARTITION_ROLES, + QUALITY_OVERVIEW_ROLES, + SCENARIO_ROLES, + SINGULAR_VALUE_ROLES, + PreparationAuditProjection, +) +from carnopy.app.preparation_audit_models import PreparationAuditModel +from carnopy.app.qml_resources import MANDATORY_QML_FILES +from carnopy.app.qml_runtime import QmlApplicationRuntime, create_qml_runtime +from carnopy.app.workspace import initialize_workspace + +ROOT = Path(__file__).resolve().parents[1] + +_BOOLEAN_ROLES = { + "conditionNumberInfinite", + "countsAvailable", + "coverageAvailable", + "eligibleRowCountAvailable", + "excludedRowCountAvailable", + "featureRankFractionAvailable", + "flagCountMatches", + "leakageAvailable", + "maximumAvailable", + "maximumSpacingAvailable", + "medianSpacingAvailable", + "minimumAvailable", + "minimumSpacingAvailable", + "nearConstantThresholdAvailable", + "numericalRankAvailable", + "rSquaredAvailable", + "rankToleranceAvailable", + "recordedFlagCountAvailable", + "inspectedFlagCountAvailable", + "relativeSpreadAvailable", + "spacingRatioAvailable", + "uniformSpacing", + "uniformSpacingAvailable", + "correlationThresholdAvailable", + "effectiveRankAvailable", + "effectiveRankFractionAvailable", +} +_REAL_ROLES = { + "actualMaximum", + "actualMinimum", + "conditionNumber", + "correlation", + "correlationThreshold", + "coverageFraction", + "effectiveRank", + "effectiveRankFraction", + "explainedVarianceRatio", + "featureRankFraction", + "maximum", + "maximumSpacing", + "meanAbsoluteError", + "medianSpacing", + "minimum", + "minimumSpacing", + "nearConstantThreshold", + "predictionMaximum", + "predictionMinimum", + "rSquared", + "rankTolerance", + "relativeSpread", + "rootMeanSquaredError", + "singularValue", + "spacingRatio", +} +_INTEGER_ROLES = { + "completedModelCount", + "conflictingTargetGroupCount", + "crossPartitionGroupCount", + "duplicateGroupCount", + "duplicateRowCount", + "duplicateStateGroupCount", + "eligibleRowCount", + "errorCount", + "evaluationPartitionCount", + "evaluationRowCount", + "excludedRowCount", + "expectedCells", + "failedModelCount", + "featureCount", + "groupOrder", + "inspectedFlagCount", + "levelCount", + "missingCells", + "multiPhaseCellCount", + "numericalRank", + "observedCells", + "order", + "partitionCount", + "recordedFlagCount", + "repeatedCellCount", + "repeatedRowCount", + "rowCount", + "spacingCount", + "targetCount", + "trainRowCount", + "transformationCount", + "transitionEdgeCount", +} + + +@pytest.fixture +def application() -> QApplication: + existing = QApplication.instance() + return existing if isinstance(existing, QApplication) else QApplication([]) + + +@pytest.fixture +def runtime(tmp_path: Path, application: QApplication) -> Iterator[QmlApplicationRuntime]: + del application + workspace = initialize_workspace(tmp_path / "workspace") + created = create_qml_runtime( + settings=QSettings(str(tmp_path / "settings.ini"), QSettings.Format.IniFormat), + initial_workspace=workspace.root, + application_arguments=[], + ) + _wait_for_idle(created) + yield created + _wait_for_idle(created) + assert created.close() + assert created.warning_capture.runtime_warnings == () + + +@pytest.fixture +def audit_view(runtime: QmlApplicationRuntime) -> Iterator[QQuickItem]: + model = PreparationAuditModel(runtime.controller) + model.replace(_projection()) + window = runtime.engine.rootObjects()[0] + assert isinstance(window, QQuickWindow) + window.setWidth(1440) + window.setHeight(1200) + component = QQmlComponent(runtime.engine) + component.loadFromModule("Carnopy", "PreparationAuditView") + assert component.status() == QQmlComponent.Status.Ready, _component_errors(component) + created = component.createWithInitialProperties({"audit": model}) + assert isinstance(created, QQuickItem), _component_errors(component) + created.setParent(window) + created.setParentItem(window.contentItem()) + created.setWidth(1180) + created.setZ(1000) + _process_events() + yield created + + +def _component_errors(component: QQmlComponent) -> str: + return "\n".join(error.toString() for error in component.errors()) + + +def _process_events() -> None: + application = QCoreApplication.instance() + assert application is not None + for _ in range(6): + application.processEvents() + + +def _wait_for_idle(runtime: QmlApplicationRuntime) -> None: + if not runtime.controller.request_coordinator.is_busy: + _process_events() + return + loop = QEventLoop() + runtime.controller.request_coordinator.busy_changed.connect( + lambda busy: None if busy else loop.quit() + ) + QTimer.singleShot(15_000, loop.quit) + loop.exec() + _process_events() + assert not runtime.controller.request_coordinator.is_busy + + +def _item(root: QQuickItem, object_name: str) -> QQuickItem: + pending = [root] + while pending: + candidate = pending.pop() + if candidate.objectName() == object_name: + return candidate + pending.extend(candidate.childItems()) + raise AssertionError(f"missing visual item: {object_name}") + + +def _row(roles: tuple[str, ...], **values: object) -> dict[str, object]: + row: dict[str, object] = {} + for role in roles: + if role in _BOOLEAN_ROLES: + row[role] = False + elif role in _REAL_ROLES: + row[role] = 0.0 + elif role in _INTEGER_ROLES: + row[role] = 0 + elif role == "groupColumns": + row[role] = [] + else: + row[role] = "" + row.update(values) + return row + + +def _projection() -> PreparationAuditProjection: + return PreparationAuditProjection( + available=True, + quality_status="completed_with_failures", + quality_errors=(), + scenario_evidence_available=True, + leakage_evidence_available=True, + duplicate_state_evidence_available=True, + grid_evidence_available=True, + matrix_evidence_available=True, + baseline_evidence_available=True, + quality_overview=( + _row( + QUALITY_OVERVIEW_ROLES, + status="completed_with_failures", + eligibleRowCount=8, + excludedRowCount=1, + eligibleRowCountAvailable=True, + excludedRowCountAvailable=True, + recordedFlagCount=2, + inspectedFlagCount=2, + recordedFlagCountAvailable=True, + inspectedFlagCountAvailable=True, + flagCountMatches=True, + scenarioStatus="completed", + matrixStatus="completed", + baselineStatus="completed_with_failures", + duplicateStatus="completed", + gridStatus="completed", + ), + ), + scenarios=( + _row( + SCENARIO_ROLES, + name="holdout", + kind="shuffle", + rowCount=8, + partitionCount=2, + transformationCount=1, + leakageAvailable=True, + ), + ), + partitions=(_row(PARTITION_ROLES, scenario="holdout", partition="train", rowCount=6),), + leakage_audits=( + _row( + LEAKAGE_ROLES, + scenario="holdout", + identityColumn="source_state_hash", + duplicateStateGroupCount=1, + ), + ), + duplicate_state_checks=( + _row( + DUPLICATE_STATE_ROLES, + status="completed", + groupColumns=["fluid", "temperature", "pressure"], + countsAvailable=True, + duplicateGroupCount=1, + duplicateRowCount=2, + ), + ), + grid_groups=( + _row( + GRID_GROUP_ROLES, + sourceRunId="run-1", + sourceFluid="Propane", + backendModel="heos", + rowCount=8, + expectedCells=9, + observedCells=8, + missingCells=1, + coverageFraction=8 / 9, + coverageAvailable=True, + phaseBoundaryStatus="completed", + ), + ), + grid_spacing=( + _row( + GRID_SPACING_ROLES, + coordinate="source_temperature_K", + levelCount=3, + minimum=300.0, + minimumAvailable=True, + maximum=320.0, + maximumAvailable=True, + spacingCount=2, + minimumSpacing=10.0, + minimumSpacingAvailable=True, + maximumSpacing=10.0, + maximumSpacingAvailable=True, + medianSpacing=10.0, + medianSpacingAvailable=True, + spacingRatio=1.0, + spacingRatioAvailable=True, + uniformSpacing=True, + uniformSpacingAvailable=True, + ), + ), + grid_phase_counts=(_row(GRID_PHASE_ROLES, phase="gas", count=5),), + matrix_checks=( + _row( + MATRIX_CHECK_ROLES, + scenario="holdout", + fitPartition="train", + status="completed", + rowCount=6, + featureCount=4, + targetCount=2, + numericalRank=2, + numericalRankAvailable=True, + featureRankFraction=0.5, + featureRankFractionAvailable=True, + effectiveRank=1.4, + effectiveRankAvailable=True, + effectiveRankFraction=0.35, + effectiveRankFractionAvailable=True, + conditionNumber=3.0, + conditionNumberAvailable=True, + ), + ), + matrix_feature_flags=( + _row( + MATRIX_FEATURE_FLAG_ROLES, + scenario="holdout", + fitPartition="train", + kind="near_constant_feature", + field="temperature", + relativeSpread=1e-14, + relativeSpreadAvailable=True, + ), + ), + singular_values=( + _row( + SINGULAR_VALUE_ROLES, + scenario="holdout", + fitPartition="train", + singularValue=3.0, + explainedVarianceRatio=0.9, + ), + ), + correlated_feature_pairs=( + _row( + CORRELATED_PAIR_ROLES, + scenario="holdout", + fitPartition="train", + left="pressure", + right="pressure_copy", + correlation=1.0, + ), + ), + feature_target_correlations=( + _row( + FEATURE_TARGET_CORRELATION_ROLES, + scenario="holdout", + fitPartition="train", + feature="pressure", + target="mass_density", + correlation=0.75, + ), + ), + baseline_checks=( + _row( + BASELINE_CHECK_ROLES, + scenario="holdout", + status="completed_with_failures", + library="scikit-learn", + libraryVersion="1.8.0", + featureCount=1, + targetCount=1, + trainRowCount=6, + trainRowCountAvailable=True, + evaluationPartitionCount=1, + evaluationRowCount=2, + completedModelCount=1, + failedModelCount=1, + policy="diagnostic metrics only", + ), + ), + baseline_metrics=( + _row( + BASELINE_METRIC_ROLES, + scenario="holdout", + model="ridge", + target="mass_density", + partition="test", + meanAbsoluteError=0.1, + rootMeanSquaredError=0.2, + rSquaredAvailable=False, + actualMinimum=1.0, + actualMaximum=2.0, + predictionMinimum=1.1, + predictionMaximum=1.9, + ), + ), + baseline_failures=( + _row( + BASELINE_FAILURE_ROLES, + scenario="holdout", + model="hist_gradient_boosting", + target="mass_density", + errorType="ValueError", + message="not enough rows", + ), + ), + ) + + +def test_preparation_audit_view_presents_every_typed_evidence_model( + runtime: QmlApplicationRuntime, + audit_view: QQuickItem, +) -> None: + expected_counts = { + "preparationAuditScenariosList": 1, + "preparationAuditPartitionsList": 1, + "preparationAuditLeakageList": 1, + "preparationAuditDuplicateStatesList": 1, + "preparationAuditGridGroupsList": 1, + "preparationAuditGridSpacingList": 1, + "preparationAuditGridPhasesList": 1, + "preparationAuditMatrixChecksList": 1, + "preparationAuditMatrixFlagsList": 1, + "preparationAuditSingularValuesList": 1, + "preparationAuditCorrelatedPairsList": 1, + "preparationAuditFeatureTargetList": 1, + "preparationAuditBaselineChecksList": 1, + "preparationAuditBaselineMetricsList": 1, + "preparationAuditBaselineFailuresList": 1, + } + for object_name, count in expected_counts.items(): + assert _item(audit_view, object_name).property("count") == count + + assert _item(audit_view, "preparationAuditStatus").property("label") == ( + "Completed with failures" + ) + assert _item(audit_view, "preparationAuditOverviewGrid").property("maximumColumns") == 2 + assert runtime.warning_capture.runtime_warnings == () + + +def test_preparation_audit_view_switches_sections_and_stacks_at_narrow_width( + runtime: QmlApplicationRuntime, + audit_view: QQuickItem, +) -> None: + audit_view.setProperty("selectedSection", 1) + _process_events() + assert _item(audit_view, "preparationAuditSectionStack").property("currentIndex") == 1 + assert _item(audit_view, "preparationAuditMatrixChecksList").isVisible() + + audit_view.setProperty("selectedSection", 2) + audit_view.setWidth(720) + _process_events() + assert _item(audit_view, "preparationAuditSectionStack").property("currentIndex") == 2 + assert _item(audit_view, "preparationAuditBaselineMetricsList").isVisible() + assert _item(audit_view, "preparationAuditOverviewGrid").property("maximumColumns") == 1 + assert _item(audit_view, "preparationAuditMatrixGrid").property("maximumColumns") == 1 + assert _item(audit_view, "preparationAuditBaselineGrid").property("maximumColumns") == 1 + assert runtime.warning_capture.runtime_warnings == () + + +def test_preparation_audit_view_keeps_unavailable_evidence_explicit( + runtime: QmlApplicationRuntime, + audit_view: QQuickItem, +) -> None: + model = audit_view.property("audit") + assert isinstance(model, PreparationAuditModel) + + model.clear() + _process_events() + + assert _item(audit_view, "preparationAuditStatus").property("label") == "Unavailable" + assert _item(audit_view, "preparationAuditScenariosList").property("count") == 0 + assert not _item(audit_view, "preparationAuditScenariosList").isVisible() + assert runtime.warning_capture.runtime_warnings == () + + +def test_preparation_audit_qml_resource_and_typed_boundary_are_explicit() -> None: + qml_root = ROOT / "src/carnopy/app/qml/Carnopy" + source = (qml_root / "components/PreparationAuditView.qml").read_text(encoding="utf-8") + qmldir = (qml_root / "qmldir").read_text(encoding="utf-8") + + assert "PreparationAuditView 1.0 components/PreparationAuditView.qml" in qmldir + assert "qml/Carnopy/components/PreparationAuditView.qml" in MANDATORY_QML_FILES + assert "required property var audit" in source + assert "root.audit.matrixChecks" in source + assert "root.audit.baselineMetrics" in source + assert "ListView" in source + assert "reuseItems: true" in source + assert "inspectionController" not in source + assert "JSON" not in source + assert "yaml" not in source.casefold() diff --git a/tests/test_app_qml_run.py b/tests/test_app_qml_run.py index 185fce4..93f97eb 100644 --- a/tests/test_app_qml_run.py +++ b/tests/test_app_qml_run.py @@ -85,7 +85,7 @@ def _wait_until( def _save_saturation_configuration(runtime: QmlApplicationRuntime) -> Path: desktop = runtime.controller - config = desktop.dataset_config_controller + config = desktop.configuration_controller workspace = config.workspace assert workspace is not None assert desktop.request_new_dataset("saturation_table") @@ -237,7 +237,7 @@ def test_run_validation_uses_the_facade_and_persists_activity( assert inspector_state is not None assert inspector_state.property("text") == "Succeeded" - workspace = desktop.dataset_config_controller.workspace + workspace = desktop.configuration_controller.workspace assert workspace is not None [record] = JobStore(workspace.private_directory).load() assert record.data is not None diff --git a/tests/test_app_qml_runtime.py b/tests/test_app_qml_runtime.py index 45ccffb..1488419 100644 --- a/tests/test_app_qml_runtime.py +++ b/tests/test_app_qml_runtime.py @@ -458,6 +458,41 @@ def test_qml_close_offers_an_explicit_transient_edit_decision( assert runtime.close() +def test_qml_busy_close_dialog_names_each_worker_policy_truthfully( + application: QApplication, + tmp_path: Path, +) -> None: + runtime = create_qml_runtime( + settings=QSettings(str(tmp_path / "busy-close.ini"), QSettings.Format.IniFormat), + application_arguments=[], + ) + root = runtime.engine.rootObjects()[0] + dialog = root.findChild(QObject, "busyShutdownDialog") + assert dialog is not None + cases = ( + ("cancel_generation", "Cancel generation and close?", "Cancel and close"), + ("cancel_sweep", "Cancel Model Sweep and close?", "Cancel and close"), + ( + "cancel_preparation", + "Cancel ML Preparation and close?", + "Cancel and close", + ), + ("force_stop_plot", "Stop plot render and close?", "Force stop and close"), + ) + + for mode, title, accept_text in cases: + runtime.controller.busyShutdownConfirmationRequested.emit(mode, "Operation detail") + application.processEvents() + assert dialog.property("opened") is True + assert dialog.property("title") == title + assert dialog.property("acceptText") == accept_text + assert dialog.property("bodyText") == "Operation detail" + dialog.close() + application.processEvents() + + assert runtime.close() + + def test_qml_runtime_teardown_is_idempotent_and_removes_the_close_filter( application: QApplication, monkeypatch: pytest.MonkeyPatch, @@ -642,4 +677,4 @@ def test_qml_sources_pass_non_writing_qt_tooling() -> None: timeout=30, ) assert completed.returncode == 0, completed.stdout + completed.stderr - assert completed.stdout == "QML checks passed for 39 file(s).\n" + assert completed.stdout == "QML checks passed for 46 file(s).\n" diff --git a/tests/test_app_qml_shell.py b/tests/test_app_qml_shell.py index ab39374..77c0530 100644 --- a/tests/test_app_qml_shell.py +++ b/tests/test_app_qml_shell.py @@ -131,7 +131,7 @@ def _wait_for_idle(runtime: QmlApplicationRuntime) -> None: assert not runtime.controller.request_coordinator.is_busy -def test_shell_uses_exact_navigation_order_and_disables_future_workflows( +def test_shell_uses_exact_navigation_order_and_enables_only_integrated_workflows( runtime: QmlApplicationRuntime, ) -> None: root = runtime.engine.rootObjects()[0] @@ -147,7 +147,7 @@ def test_shell_uses_exact_navigation_order_and_disables_future_workflows( ) assert tuple( model.data(model.index(row, 0), available_role) for row in range(model.rowCount()) - ) == (True, True, True, True, True, True, True, False, False, False) + ) == (True, True, True, True, True, True, True, True, True, False) nav_source = (ROOT / "src/carnopy/app/qml/Carnopy/components/NavRail.qml").read_text( encoding="utf-8" ) @@ -165,6 +165,10 @@ def test_shell_uses_exact_navigation_order_and_disables_future_workflows( assert "root.inspectAvailable" in nav_source assert '!== "activity"' in nav_source assert "root.activityAvailable" in nav_source + assert 'pageKey !== "sweeps"' in nav_source + assert "root.sweepsAvailable" in nav_source + assert 'pageKey !== "preparation"' in nav_source + assert "root.preparationAvailable" in nav_source assert root.property("hasFake3dViewport") is False diff --git a/tests/test_app_qml_sweep.py b/tests/test_app_qml_sweep.py new file mode 100644 index 0000000..ee92520 --- /dev/null +++ b/tests/test_app_qml_sweep.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import os +from collections.abc import Iterator +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from PySide6.QtCore import QCoreApplication, QEventLoop, QObject, QSettings, QTimer +from PySide6.QtQml import QQmlComponent +from PySide6.QtQuick import QQuickItem, QQuickWindow +from PySide6.QtWidgets import QApplication + +from carnopy.app.qml_resources import MANDATORY_QML_FILES +from carnopy.app.qml_runtime import QmlApplicationRuntime, create_qml_runtime +from carnopy.app.workspace import initialize_workspace +from carnopy.templates import template_text + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def application() -> QApplication: + existing = QApplication.instance() + return existing if isinstance(existing, QApplication) else QApplication([]) + + +@pytest.fixture +def runtime(tmp_path: Path, application: QApplication) -> Iterator[QmlApplicationRuntime]: + del application + workspace = initialize_workspace(tmp_path / "workspace") + created = create_qml_runtime( + settings=QSettings(str(tmp_path / "settings.ini"), QSettings.Format.IniFormat), + initial_workspace=workspace.root, + application_arguments=[], + ) + _wait_for_idle(created) + yield created + _wait_for_idle(created) + assert created.close() + assert created.warning_capture.runtime_warnings == () + + +@pytest.fixture +def sweep_page(runtime: QmlApplicationRuntime) -> Iterator[QQuickItem]: + desktop = runtime.controller + assert desktop.configuration_controller.new_sweep() + _process_events() + root = runtime.engine.rootObjects()[0] + assert isinstance(root, QQuickWindow) + root.setWidth(1440) + root.setHeight(1100) + component = QQmlComponent(runtime.engine) + component.loadFromModule("Carnopy", "ModelSweepPage") + assert component.status() == QQmlComponent.Status.Ready, _component_errors(component) + created = component.createWithInitialProperties( + { + "configController": desktop.configuration_controller, + "desktopController": desktop, + "sweepDraft": desktop.configuration_controller.sweep_draft, + "workflowController": desktop.sweep_workflow_controller, + "expectedColumns": 3, + "dialogsEnabled": False, + } + ) + assert isinstance(created, QQuickItem), _component_errors(component) + created.setObjectName("directModelSweepPage") + created.setParent(root) + created.setParentItem(root.contentItem()) + created.setWidth(root.width()) + created.setHeight(root.height()) + created.setZ(1000) + _process_events() + yield created + created.setParentItem(None) + created.deleteLater() + component.deleteLater() + _process_events() + + +def _component_errors(component: QQmlComponent) -> str: + return "\n".join(error.toString() for error in component.errors()) + + +def _wait_for_idle(runtime: QmlApplicationRuntime) -> None: + if not runtime.controller.request_coordinator.is_busy: + runtime.application.processEvents() + return + loop = QEventLoop() + runtime.controller.request_coordinator.busy_changed.connect( + lambda busy: None if busy else loop.quit() + ) + QTimer.singleShot(15_000, loop.quit) + loop.exec() + runtime.application.processEvents() + assert not runtime.controller.request_coordinator.is_busy + + +def _process_events() -> None: + application = QCoreApplication.instance() + assert application is not None + for _ in range(6): + application.processEvents() + + +def _item(root: QQuickItem, object_name: str) -> QQuickItem: + pending = [root] + while pending: + candidate = pending.pop() + if candidate.objectName() == object_name: + return candidate + pending.extend(candidate.childItems()) + raise AssertionError(f"missing visual item: {object_name}") + + +def _visual_names(root: QQuickItem) -> set[str]: + names: set[str] = set() + pending = [root] + while pending: + candidate = pending.pop() + if candidate.objectName(): + names.add(candidate.objectName()) + pending.extend(candidate.childItems()) + return names + + +def _visible_item(root: QQuickWindow, object_name: str) -> QQuickItem: + pending = [root.contentItem()] + matches: list[QQuickItem] = [] + while pending: + candidate = pending.pop() + if candidate.objectName() == object_name and candidate.isVisible(): + matches.append(candidate) + pending.extend(candidate.childItems()) + assert len(matches) == 1 + return matches[0] + + +def test_shell_creates_and_enables_the_structured_sweep_surface( + runtime: QmlApplicationRuntime, +) -> None: + desktop = runtime.controller + root = runtime.engine.rootObjects()[0] + assert isinstance(root, QQuickWindow) + root.setWidth(1440) + root.setHeight(1200) + _process_events() + + workspace_page = root.findChild(QObject, "workspacePage") + sweep_button = _visible_item(root, "newModelSweepButton") + sweep_navigation = _visible_item(root, "nav-sweeps") + assert workspace_page is not None + assert sweep_button.property("enabled") is True + assert sweep_navigation.property("enabled") is True + + workspace_page.newSweepRequested.emit() + _process_events() + + page = root.findChild(QObject, "modelSweepPage") + command_bar = root.findChild(QObject, "documentCommandBar") + inspector = _visible_item(root, "sweepWorkflowContextInspector") + assert page is not None + assert command_bar is not None + assert inspector is not None + assert desktop.configuration_controller.get_document_kind() == "model_sweep" + assert root.property("currentPage") == "sweeps" + assert page.property("visible") is True + assert page.property("documentActive") is True + assert page.property("sweepDraft") is desktop.configuration_controller.sweep_draft + assert page.property("workflowController") is desktop.sweep_workflow_controller + assert command_bar.property("pageTitle") == "Model Sweeps" + assert command_bar.property("statusLabel") == "Unsaved Sweep" + assert inspector.property("visible") is True + assert runtime.warning_capture.runtime_warnings == () + + +def test_shell_uses_generic_open_and_dirty_replacement_for_sweeps( + runtime: QmlApplicationRuntime, + tmp_path: Path, +) -> None: + desktop = runtime.controller + controller = desktop.configuration_controller + root = runtime.engine.rootObjects()[0] + assert isinstance(root, QQuickWindow) + assert desktop.request_new_dataset("property_table") + assert controller.get_dirty() + assert root.setProperty("currentPage", "workspace") + _process_events() + + workspace_page = root.findChild(QObject, "workspacePage") + discard_dialog = root.findChild(QObject, "configurationDiscardDialog") + assert workspace_page is not None + assert discard_dialog is not None + assert _visible_item(root, "newModelSweepButton").property("enabled") is True + workspace_page.newSweepRequested.emit() + _process_events() + assert discard_dialog.property("opened") is True + assert controller.get_document_kind() == "dataset" + + discard_dialog.accept() + _process_events() + assert controller.get_document_kind() == "model_sweep" + assert root.property("currentPage") == "sweeps" + + assert desktop.request_close_configuration(True) + source = tmp_path / "workspace" / "configs" / "opened-sweep.yaml" + source.write_text(template_text("model_sweep"), encoding="utf-8") + root.configurationImportRequested.emit(str(source), False) + _process_events() + _wait_for_idle(runtime) + _process_events() + assert controller.get_document_kind() == "model_sweep" + assert controller.document is not None + assert controller.document.source_path == source.resolve() + assert root.property("currentPage") == "sweeps" + assert runtime.warning_capture.runtime_warnings == () + + +def test_integrated_sweep_result_remains_inspectable_after_document_replacement( + runtime: QmlApplicationRuntime, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + desktop = runtime.controller + root = runtime.engine.rootObjects()[0] + assert isinstance(root, QQuickWindow) + assert desktop.request_new_sweep() + output = tmp_path / "workspace" / "outputs" / "finalized-sweep" + inspected: list[str] = [] + monkeypatch.setattr( + desktop.inspection_controller, + "inspect_source", + lambda value: inspected.append(str(value)) or True, + ) + desktop.sweep_workflow_controller._result = {"output_directory": str(output)} + desktop.sweep_workflow_controller.state_changed.emit() + _process_events() + + assert desktop.request_new_dataset("property_table", True) + assert root.property("currentPage") == "dataset" + assert root.setProperty("currentPage", "sweeps") + _process_events() + page = root.findChild(QObject, "modelSweepPage") + assert page is not None + assert page.property("documentActive") is False + assert _visible_item(root, "modelSweepNoDocumentCard").isVisible() + assert desktop.sweep_workflow_controller.get_result_relation() == "unrelated" + + inspect_button = _visible_item(root, "sweepInspectResultButton") + assert inspect_button.property("enabled") is True + assert desktop.request_workflow_inspect_result("sweep") + _process_events() + assert inspected == [str(output)] + assert root.property("currentPage") == "inspect" + assert runtime.warning_capture.runtime_warnings == () + + +def test_hidden_model_sweep_page_binds_complete_authoritative_surface( + runtime: QmlApplicationRuntime, + sweep_page: QQuickItem, +) -> None: + desktop = runtime.controller + draft = desktop.configuration_controller.sweep_draft + + assert sweep_page.property("configController") is desktop.configuration_controller + assert sweep_page.property("desktopController") is desktop + assert sweep_page.property("sweepDraft") is draft + assert sweep_page.property("workflowController") is desktop.sweep_workflow_controller + assert sweep_page.property("locked") is False + assert draft.dataset_draft.samplers.rowCount() == 2 + assert _item(sweep_page, "modelSweepPageFlickable").property("pixelAligned") is True + assert _item(sweep_page, "modelSweepDefinitionGrid").property("maximumColumns") == 3 + + expected = { + "modelSweepModelsCard", + "modelSweepReferenceModel", + "modelSweepMode", + "modelSweepFluids", + "modelSweepProperties", + "samplerEditor-temperature", + "samplerEditor-pressure", + "modelSweepOutputsCard", + "modelSweepComparisonsCard", + "sweepWorkflowRunPanel", + "sweepPlanButton", + "sweepExecuteButton", + "sweepInspectResultButton", + "sweepPlanBlocker-0", + } + names = _visual_names(sweep_page) + assert expected <= names, sorted(name for name in names if "ampler" in name) + assert "HEOS" in _item(sweep_page, "modelSweepModel-heos").property("text") + assert not _item(sweep_page, "sweepPlanButton").property("enabled") + assert not _item(sweep_page, "sweepExecuteButton").property("enabled") + assert "save" in _item(sweep_page, "sweepPlanBlocker-0").property("text").casefold() + assert runtime.warning_capture.runtime_warnings == () + + +def test_model_sweep_page_routes_edits_and_commits_one_temporary_comparison( + runtime: QmlApplicationRuntime, + sweep_page: QQuickItem, +) -> None: + desktop = runtime.controller + draft = desktop.configuration_controller.sweep_draft + selected_before = draft.get_selected_models() + desktop.request_sweep_model_selection("srk", False) + _process_events() + assert draft.get_selected_models() == [value for value in selected_before if value != "srk"] + assert desktop.configuration_controller.get_dirty() + + assert desktop.request_sweep_add_comparison() + _process_events() + assert draft.get_has_active_comparison_edit() + editor = _item(sweep_page, "comparisonPlotEditor") + assert _item(editor, "comparisonPlotName").property("enabled") + assert _item(editor, "comparisonPlotKind") is not None + assert _item(editor, "comparisonPlotFluid") is not None + assert _item(editor, "comparisonPlotProperty") is not None + assert _item(editor, "comparisonPlotX") is not None + assert _item(editor, "comparisonPlotFilters") is not None + + active = draft.get_active_comparison_draft() + assert active is not None + desktop.request_sweep_comparison_field_change(active, "name", "density-comparison") + _process_events() + commit = _item(editor, "comparisonPlotCommitButton") + assert commit.property("enabled") + assert desktop.request_sweep_commit_comparison() + _process_events() + + assert not draft.get_has_active_comparison_edit() + assert draft.comparison_plots_model.rowCount() == 1 + assert "Property comparison" in draft.comparison_plots_model.items[0].display + assert _item(sweep_page, "modelSweepComparisonList").property("count") == 1 + assert runtime.warning_capture.runtime_warnings == () + + +def test_model_sweep_focus_and_responsive_state_remain_warning_free( + runtime: QmlApplicationRuntime, + sweep_page: QQuickItem, +) -> None: + reference = _item(sweep_page, "modelSweepReferenceModel") + sweep_page.setProperty("attentionField", "sweep.backend.reference_model") + sweep_page.setProperty("attentionSerial", 1) + _process_events() + assert reference.property("activeFocus") is True + + sweep_page.setWidth(720) + sweep_page.setProperty("expectedColumns", 1) + _process_events() + assert _item(sweep_page, "modelSweepDefinitionGrid").property("maximumColumns") == 1 + sweep_page.setWidth(1440) + sweep_page.setProperty("expectedColumns", 3) + _process_events() + assert _item(sweep_page, "modelSweepDefinitionGrid").property("maximumColumns") == 3 + assert runtime.warning_capture.runtime_warnings == () + + +def test_sweep_qml_resources_and_controller_boundary_are_explicit() -> None: + qml_root = ROOT / "src/carnopy/app/qml/Carnopy" + page_source = (qml_root / "pages/ModelSweepPage.qml").read_text(encoding="utf-8") + editor_source = (qml_root / "components/ComparisonPlotEditor.qml").read_text(encoding="utf-8") + inspector_source = (qml_root / "components/WorkflowContextInspector.qml").read_text( + encoding="utf-8" + ) + main_source = (qml_root / "Main.qml").read_text(encoding="utf-8") + qmldir = (qml_root / "qmldir").read_text(encoding="utf-8") + + assert "ModelSweepPage 1.0 pages/ModelSweepPage.qml" in qmldir + assert "ComparisonPlotEditor 1.0 components/ComparisonPlotEditor.qml" in qmldir + assert "WorkflowRunPanel 1.0 components/WorkflowRunPanel.qml" in qmldir + assert "WorkflowContextInspector 1.0 components/WorkflowContextInspector.qml" in qmldir + assert "qml/Carnopy/pages/ModelSweepPage.qml" in MANDATORY_QML_FILES + assert "qml/Carnopy/components/ComparisonPlotEditor.qml" in MANDATORY_QML_FILES + assert "qml/Carnopy/components/WorkflowRunPanel.qml" in MANDATORY_QML_FILES + assert "qml/Carnopy/components/WorkflowContextInspector.qml" in MANDATORY_QML_FILES + assert "requestSweep" in page_source + assert "requestWorkflow" in page_source + assert "requestSweep" in editor_source + assert 'Accessible.name: qsTr("Sweep reference model")' in page_source + assert 'Accessible.name: qsTr("Committed comparison plots")' in page_source + assert 'Accessible.name: qsTr("Comparison plot name")' in editor_source + assert 'Accessible.name: qsTr("Comparison delta metric")' in editor_source + assert ".setModelSelected(" not in page_source + assert ".setReferenceModel(" not in page_source + assert ".beginAddComparison(" not in page_source + assert ".commitComparison(" not in page_source + assert ".setName(" not in editor_source + assert "yaml" not in editor_source.casefold() + assert 'pageKey: "sweeps"' in main_source or 'currentPage === "sweeps"' in main_source + assert "planBlockingReasons" in inspector_source + assert "resultRelation" in inspector_source + assert "JSON" not in inspector_source diff --git a/tests/test_app_qml_visualization.py b/tests/test_app_qml_visualization.py index 1b06f1e..c7c97b8 100644 --- a/tests/test_app_qml_visualization.py +++ b/tests/test_app_qml_visualization.py @@ -412,7 +412,7 @@ def test_historical_visualization_stays_open_without_a_configuration( desktop = runtime.controller root = runtime.engine.rootObjects()[0] assert isinstance(root, QQuickWindow) - assert not desktop.dataset_config_controller.get_has_document() + assert not desktop.configuration_controller.get_has_document() source = desktop.workspace_controller.workspace.outputs / "historical.parquet" source.write_bytes(b"source") desktop.session_plot_controller._inspection_changed(_session_plot_context(source)) diff --git a/tests/test_app_qml_workspace.py b/tests/test_app_qml_workspace.py index 16c5b31..195fdba 100644 --- a/tests/test_app_qml_workspace.py +++ b/tests/test_app_qml_workspace.py @@ -136,7 +136,7 @@ def test_workspace_page_starts_unavailable_and_uses_only_composition_facade( assert "prepare_create" not in _method_names(runtime.controller.workspace_controller) assert "prepare_open" not in _method_names(runtime.controller.workspace_controller) assert "commit_pending" not in _method_names(runtime.controller.workspace_controller) - assert "set_workspace" not in _method_names(runtime.controller.dataset_config_controller) + assert "set_workspace" not in _method_names(runtime.controller.configuration_controller) initialize_dialog = root.findChild(QObject, "initializeFolderDialog") open_dialog = root.findChild(QObject, "openFolderDialog") @@ -230,6 +230,15 @@ def test_qml_facade_creates_workspace_from_parent_and_refreshes_bound_state( assert runtime.controller.get_workspace_root_path() == str(target.resolve()) assert recent.rowCount() == 1 assert root.findChild(QObject, "newDatasetModeGrid") is not None + assert root.findChild(QObject, "newModelSweepCard") is not None + sweep_button = root.findChild(QObject, "newModelSweepButton") + assert sweep_button is not None + assert sweep_button.property("enabled") is True + assert root.findChild(QObject, "newPreparationCard") is not None + preparation_button = root.findChild(QObject, "newPreparationButton") + assert preparation_button is not None + assert preparation_button.property("enabled") is True + assert preparation_button.property("text") == "Choose source in Inspect" assert runtime.warning_capture.runtime_warnings == () diff --git a/tests/test_app_request_coordinator.py b/tests/test_app_request_coordinator.py index 05a66d2..ff0463c 100644 --- a/tests/test_app_request_coordinator.py +++ b/tests/test_app_request_coordinator.py @@ -191,6 +191,23 @@ def test_coordinator_routes_events_only_to_the_owner_and_preserves_envelope( assert not session.force_stop() +def test_configuration_owner_admits_only_its_generic_requests( + application: QCoreApplication, +) -> None: + del application + transport = StubTransport() + coordinator = coordinator_for(transport) + + for request_type in ("load_configuration", "validate_configuration"): + session = coordinator.start_request("configuration", request_type, {}) + assert session.request_type == request_type + assert transport.started[-1][1] == request_type + transport.finish(payload={}) + + with pytest.raises(ValueError, match="not owned by 'sweep'"): + coordinator.start_request("sweep", "load_configuration", {}) + + def test_reservation_is_nonbusy_blocks_reentry_and_preserves_uuid( application: QCoreApplication, ) -> None: diff --git a/tests/test_app_scenario_draft.py b/tests/test_app_scenario_draft.py new file mode 100644 index 0000000..63c80d8 --- /dev/null +++ b/tests/test_app_scenario_draft.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from carnopy.app.scenario_draft import ScenarioDraft +from carnopy.preparation.models import ScenarioConfig + +SCENARIOS = ( + {"name": "all", "kind": "unsplit"}, + { + "name": "shuffle", + "kind": "shuffle", + "seed": 42, + "partitions": {"train": 0.8, "test": 0.2}, + }, + { + "name": "strata", + "kind": "stratified_hash", + "seed": 7, + "partitions": {"train": 0.7, "validation": 0.1, "test": 0.2}, + "strata": { + "categorical": ["phase"], + "numeric_bins": {"temperature": [300.0]}, + }, + }, + { + "name": "block", + "kind": "coordinate_block", + "holdouts": {"test": {"pressure": {"min": 1.0, "max": 2.0}}}, + "remainder": "train", + }, + { + "name": "range", + "kind": "range_holdout", + "field": "pressure", + "holdouts": {"test": {"min": 1.0, "max": 2.0}}, + "remainder": "train", + }, + { + "name": "fluid", + "kind": "leave_fluid_out", + "holdouts": {"test": ["Propane"]}, + "remainder": "train", + }, + { + "name": "phase", + "kind": "phase_holdout", + "holdouts": {"test": ["gas"]}, + "remainder": "train", + }, + { + "name": "model", + "kind": "model_holdout", + "holdouts": {"test": ["pr"]}, + "remainder": "train", + "transformations": [{"field": "pressure", "methods": ["log10", "standard"]}], + }, +) + + +def _normalized(payload: dict[str, object]) -> dict[str, object]: + return ScenarioConfig.model_validate(payload).model_dump( + mode="json", + exclude_none=True, + ) + + +@pytest.mark.parametrize("payload", SCENARIOS) +def test_scenario_draft_round_trips_all_public_kinds(payload: dict[str, object]) -> None: + draft = ScenarioDraft(field_choices=("temperature", "pressure"), payload=payload) + + assert draft.get_locally_valid() + assert draft.payload() == _normalized(payload) + + +def test_scenario_models_project_nested_values_in_deterministic_order() -> None: + draft = ScenarioDraft(payload=SCENARIOS[2]) + + assert draft.partition_rows.rows() == ( + {"partition": "train", "ratio": 0.7}, + {"partition": "validation", "ratio": 0.1}, + {"partition": "test", "ratio": 0.2}, + ) + assert draft.strata_rows.rows() == ({"field": "phase"},) + assert draft.numeric_bin_rows.rows() == ( + { + "field": "temperature", + "boundaries": [300.0], + "summary": "300", + }, + ) + + holdout = ScenarioDraft(payload=SCENARIOS[3]) + assert holdout.holdout_rows.rows() == ( + { + "partition": "test", + "summary": "pressure: 1 … 2", + "kind": "coordinate_block", + }, + ) + + +def test_kind_change_requires_confirmation_only_after_shape_state_exists() -> None: + draft = ScenarioDraft() + requests: list[str] = [] + draft.kind_change_requested.connect(requests.append) + + assert draft.request_kind_change("shuffle") + assert draft.get_kind() == "shuffle" + assert draft.get_seed_text() == "42" + assert draft.partition_rows.rows() == ( + {"partition": "train", "ratio": 0.8}, + {"partition": "test", "ratio": 0.2}, + ) + + assert not draft.request_kind_change("range_holdout") + assert requests == ["range_holdout"] + assert draft.get_kind() == "shuffle" + assert not draft.apply_kind_change("range_holdout", False) + assert draft.apply_kind_change("range_holdout", True) + assert draft.get_kind() == "range_holdout" + assert draft.get_seed_text() == "42" + assert draft.get_remainder() == "train" + assert draft.partition_rows.rows() == () + + +def test_range_coordinate_and_categorical_holdout_editing() -> None: + range_draft = ScenarioDraft(payload=SCENARIOS[4]) + assert range_draft.set_range_holdout("validation", "3", "4.5") + assert not range_draft.set_range_holdout("validation", "5", "4") + assert range_draft.remove_holdout("test") + assert range_draft.payload()["holdouts"] == {"validation": {"min": 3.0, "max": 4.5}} + + block = ScenarioDraft(payload=SCENARIOS[3]) + assert block.set_coordinate_holdout("test", "temperature", "300", "320") + assert block.remove_coordinate_field("test", "pressure") + assert block.payload()["holdouts"] == {"test": {"temperature": {"min": 300.0, "max": 320.0}}} + + categorical = ScenarioDraft(payload=SCENARIOS[5]) + assert categorical.set_categorical_holdout("validation", "Butane, Isopentane") + assert not categorical.set_categorical_holdout("validation", "Butane, Butane") + assert categorical.payload()["holdouts"]["validation"] == [ + "Butane", + "Isopentane", + ] + + +def test_strata_and_transformations_preserve_order_and_reject_duplicates() -> None: + draft = ScenarioDraft(payload=SCENARIOS[2]) + assert draft.set_strata_categorical("phase, fluid") + assert not draft.set_strata_categorical("phase, phase") + assert draft.set_numeric_bins("pressure", "100000, 200000, 300000") + assert not draft.set_numeric_bins("pressure", "200000, 100000") + + assert draft.add_transformation("pressure", "log10, standard") + assert draft.add_transformation("temperature", "robust") + assert not draft.add_transformation("pressure", "log10, standard") + assert not draft.add_transformation("pressure", "standard, standard") + assert draft.move_transformation(1, 0) + + assert draft.payload()["transformations"] == [ + {"field": "temperature", "methods": ["robust"]}, + {"field": "pressure", "methods": ["log10", "standard"]}, + ] + assert draft.remove_transformation(0) + assert draft.transformation_rows.rows() == ( + { + "field": "pressure", + "methods": ["log10", "standard"], + "summary": "pressure · log10 → standard", + }, + ) + + +def test_invalid_scalar_state_remains_visible_with_stable_focus() -> None: + draft = ScenarioDraft(payload=SCENARIOS[1]) + + assert draft.set_seed_text("not-an-integer") + + assert not draft.get_locally_valid() + assert "integer" in draft.get_issue() + assert draft.get_first_invalid_field() == "preparation.scenario.active.seed" + assert draft.get_first_invalid_row() == -1 + + assert draft.set_seed_text("42") + assert draft.set_partition("train", "-0.2") + assert not draft.get_locally_valid() + assert draft.get_first_invalid_field() == "preparation.scenario.active.partitions" + assert draft.get_first_invalid_row() == 0 + + +def test_source_field_choices_do_not_mutate_or_hide_imported_configuration() -> None: + draft = ScenarioDraft( + field_choices=("temperature",), + payload={ + "name": "legacy-range", + "kind": "range_holdout", + "field": "legacy_pressure", + "holdouts": {"test": {"min": 1.0, "max": 2.0}}, + "remainder": "train", + "transformations": [{"field": "legacy_density", "methods": ["standard"]}], + }, + ) + changes: list[None] = [] + contexts: list[None] = [] + draft.changed.connect(lambda: changes.append(None)) + draft.field_choices_changed.connect(lambda: contexts.append(None)) + baseline = draft.payload() + + assert draft.get_field_choices() == [ + "temperature", + "legacy_pressure", + "legacy_density", + ] + assert draft.set_field_choices(("temperature", "mass_density")) + assert changes == [] + assert contexts == [None] + assert draft.payload() == baseline + assert "legacy_pressure" in draft.get_field_choices() + + +def test_detached_payload_does_not_share_nested_scenario_state() -> None: + draft = ScenarioDraft(payload=SCENARIOS[7]) + + detached = draft.detached_payload() + detached["holdouts"]["test"].append("srk") + detached["transformations"][0]["methods"].append("robust") + + assert draft.payload() == _normalized(SCENARIOS[7]) + + +def test_scenario_draft_import_is_qtcore_only_and_scientifically_isolated() -> None: + code = """ +import sys +import carnopy.app.scenario_draft +for name in ( + "PySide6.QtWidgets", "CoolProp", "numpy", "pandas", "pyarrow", "matplotlib", + "carnopy.cli", "carnopy.pipeline", "carnopy.preparation.models", + "carnopy.preparation.scenarios", +): + if name in sys.modules: + raise SystemExit(name) +""" + completed = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr diff --git a/tests/test_app_sweep_draft.py b/tests/test_app_sweep_draft.py new file mode 100644 index 0000000..30f1140 --- /dev/null +++ b/tests/test_app_sweep_draft.py @@ -0,0 +1,495 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from typing import Any, cast + +import pytest +import yaml + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from carnopy.app.sweep_draft import SweepDraft +from carnopy.config.sweep import ModelSweepConfig +from carnopy.templates import template_text + + +def _capabilities( + *, + models: list[str] | None = None, + density_models: list[str] | None = None, +) -> dict[str, object]: + available_models = models or ["heos", "pr", "srk"] + return { + "model": "heos", + "models": available_models, + "modes": ["property_table", "saturation_table", "vapor_mass_fraction_table"], + "units_by_axis": { + "temperature": ["K", "degC"], + "pressure": ["Pa", "bar"], + "vapor_mass_fraction": ["1"], + }, + "dataset_formats": ["csv", "parquet"], + "fluids": [{"name": "Propane", "aliases": ["R290"]}], + "property_catalog": [ + { + "name": "mass_density", + "supported_models": density_models or available_models, + }, + { + "name": "specific_enthalpy", + "supported_models": available_models, + }, + ], + "reference_dependent_fields": [], + "reference_state": {}, + "visualization": { + "plot_kinds": [], + "formats": ["png", "svg", "pdf"], + "scales": ["linear", "log"], + "kind_contracts": {}, + "fields": [], + "display_units": {}, + "categorical_values": {}, + }, + } + + +def _sweep_payload(mode: str = "property_table") -> dict[str, Any]: + value = yaml.safe_load(template_text(cast(Any, mode))) + assert isinstance(value, dict) + value["document_type"] = "model_sweep" + value["backend"] = { + "name": "coolprop", + "models": ["heos", "pr", "srk"], + "reference_model": "heos", + } + value["fluids"] = ["Propane"] + value["properties"] = ["mass_density"] + return value + + +def _normalized(payload: dict[str, Any]) -> dict[str, Any]: + return ModelSweepConfig.model_validate(payload).model_dump( + mode="json", + by_alias=True, + exclude_none=True, + ) + + +def _sweep_with_comparisons() -> dict[str, Any]: + payload = _sweep_payload() + payload["comparison_plots"] = { + "format": "svg", + "plots": [ + { + "name": "density-comparison", + "kind": "property_comparison", + "fluid": "Propane", + "property": "mass_density", + "x": "temperature", + "group_by": "pressure", + "models": ["heos", "pr", "srk"], + }, + { + "name": "density-delta", + "kind": "property_delta", + "fluid": "Propane", + "property": "mass_density", + "x": "temperature", + "group_by": "pressure", + "models": ["pr", "srk"], + "delta_metric": "signed_absolute_difference", + "format": "pdf", + }, + ], + } + return payload + + +@pytest.mark.parametrize( + "mode", + ["property_table", "saturation_table", "vapor_mass_fraction_table"], +) +def test_sweep_draft_round_trips_every_dataset_mode(mode: str) -> None: + payload = _sweep_payload(mode) + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(payload) + + assert draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.payload() == _normalized(payload) + assert draft.dataset_draft.get_mode_name() == mode + assert draft.dataset_draft.selected_fluid_values() == ("Propane",) + assert draft.dataset_draft.selected_property_values() == ("mass_density",) + assert draft.dataset_draft.output_selected("csv") + assert draft.dataset_draft.output_selected("parquet") + + +@pytest.mark.parametrize( + "sampler", + [ + {"kind": "explicit", "values": [280.0, 300.0], "unit": "K"}, + {"kind": "linspace", "start": 280.0, "stop": 340.0, "num": 5, "unit": "K"}, + {"kind": "stepspace", "start": 280.0, "stop": 340.0, "step": 15.0, "unit": "K"}, + {"kind": "geomspace", "start": 280.0, "stop": 340.0, "num": 5, "unit": "K"}, + { + "kind": "logspace", + "start_exp": 2.0, + "stop_exp": 3.0, + "num": 5, + "base": 10.0, + "unit": "K", + }, + ], +) +def test_sweep_draft_round_trips_every_sampler_shape(sampler: dict[str, object]) -> None: + payload = _sweep_payload() + payload["grid"]["temperature"] = sampler + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(payload) + + assert draft.get_locally_valid() + assert draft.payload() == _normalized(payload) + temperature = draft.dataset_draft.sampler("temperature") + assert temperature is not None + assert temperature.get_kind() == sampler["kind"] + + +def test_sweep_reference_and_model_selection_preserve_explicit_constraints() -> None: + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(_sweep_payload()) + messages: list[str] = [] + draft.message.connect(messages.append) + + assert not draft.set_model_selected("heos", False) + assert messages == ["Choose another reference model before removing this model."] + assert draft.set_reference_model("pr") + assert draft.dataset_draft.get_model_name() == "pr" + assert draft.set_model_selected("heos", False) + assert draft.get_selected_models() == ["pr", "srk"] + assert draft.get_dirty() + assert draft.payload()["backend"] == { + "name": "coolprop", + "models": ["pr", "srk"], + "reference_model": "pr", + } + + assert draft.set_model_selected("srk", False) + assert not draft.get_locally_valid() + assert draft.get_first_invalid_field() == "sweep.backend.models" + assert draft.set_model_selected("srk", True) + assert draft.get_locally_valid() + draft.mark_baseline() + assert not draft.get_dirty() + + +def test_sweep_dataset_fields_edit_through_existing_draft_models() -> None: + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(_sweep_payload()) + + assert draft.dataset_draft.add_fluid("R290") is False + assert draft.dataset_draft.add_property("specific_enthalpy") + assert draft.dataset_draft.set_output_selected("csv", False) + pressure = draft.dataset_draft.sampler("pressure") + assert pressure is not None + pressure.set_text("num", "7") + + value = draft.payload() + assert value["properties"] == ["mass_density", "specific_enthalpy"] + assert value["outputs"] == {"dataset_formats": ["parquet"]} + assert value["grid"]["pressure"]["num"] == 7 + assert draft.get_dirty() + + +def test_sweep_mode_and_coordinate_recomposition_require_confirmation() -> None: + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(_sweep_payload()) + + assert not draft.apply_mode_change("saturation_table", False) + assert draft.dataset_draft.get_mode_name() == "property_table" + assert draft.apply_mode_change("saturation_table", True) + assert draft.dataset_draft.get_mode_name() == "saturation_table" + assert draft.get_dirty() + + assert not draft.apply_coordinate_change("pressure", False) + assert draft.dataset_draft.get_coordinate_name() == "temperature" + assert draft.apply_coordinate_change("pressure", True) + assert draft.dataset_draft.get_coordinate_name() == "pressure" + assert draft.get_locally_valid() + assert set(draft.payload()["grid"]) == {"pressure"} + + +def test_imported_capability_incompatibility_is_retained_clean_and_blocking() -> None: + payload = _sweep_payload() + draft = SweepDraft() + draft.apply_capabilities(_capabilities(models=["heos", "pr"], density_models=["heos"])) + draft.load_payload(payload) + + assert not draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.get_selected_models() == ["heos", "pr", "srk"] + assert draft.dataset_draft.selected_property_values() == ("mass_density",) + assert "srk" in draft.get_issue() + unavailable = next(item for item in draft.model_choices.items if item.value == "srk") + assert unavailable.selected + assert not unavailable.compatible + + assert draft.set_model_selected("srk", False) + assert "mass_density" in draft.get_issue() + assert draft.get_first_invalid_field() == "sweep.properties" + assert draft.get_first_invalid_row() == 0 + with pytest.raises(ValueError, match="cannot mark an invalid model sweep"): + draft.mark_baseline() + + +def test_unavailable_imported_reference_model_is_retained_clean_and_blocking() -> None: + payload = _sweep_payload() + payload["backend"]["reference_model"] = "srk" + draft = SweepDraft() + draft.apply_capabilities(_capabilities(models=["heos", "pr"])) + + draft.load_payload(payload) + + assert draft.get_reference_model() == "srk" + assert draft.dataset_draft.get_model_name() == "srk" + assert draft.dataset_draft.selected_fluid_values() == ("Propane",) + assert draft.dataset_draft.selected_property_values() == ("mass_density",) + assert not draft.get_locally_valid() + assert not draft.get_dirty() + assert "srk" in draft.get_issue() + + +def test_import_before_capabilities_remains_clean_until_rechecked() -> None: + payload = _sweep_payload() + draft = SweepDraft() + + draft.load_payload(payload) + + assert not draft.get_locally_valid() + assert draft.get_issue() == "Sweep capabilities are not loaded." + assert not draft.get_dirty() + assert draft.get_selected_models() == ["heos", "pr", "srk"] + + draft.apply_capabilities(_capabilities()) + assert draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.payload() == _normalized(payload) + + +def test_comparison_payload_handoff_is_detached_and_retained_when_incompatible() -> None: + payload = _sweep_payload() + payload["comparison_plots"] = { + "format": "svg", + "plots": [ + { + "name": "density-comparison", + "kind": "property_comparison", + "fluid": "Propane", + "property": "mass_density", + "x": "temperature", + "models": ["heos", "pr", "srk"], + } + ], + } + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(payload) + expected = _normalized(payload) + + assert draft.payload() == expected + preserved = draft.comparison_plots_payload() + assert preserved == expected["comparison_plots"] + assert preserved is not None + preserved["format"] = "pdf" + assert draft.comparison_plots_payload() == expected["comparison_plots"] + + assert draft.set_reference_model("pr") + assert draft.set_model_selected("heos", False) + assert not draft.get_locally_valid() + assert draft.comparison_plots_payload() == expected["comparison_plots"] + + +def test_structured_comparisons_round_trip_with_effective_summaries() -> None: + payload = _sweep_with_comparisons() + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(payload) + + assert draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.payload() == _normalized(payload) + assert draft.get_comparison_format() == "svg" + assert draft.comparison_plots_model.values == ( + "density-comparison", + "density-delta", + ) + first, second = draft.comparison_plots_model.items + assert "Property comparison" in first.display + assert "HEOS, PR, SRK" in first.display + assert first.display.endswith("SVG") + assert "Property delta" in second.display + assert "absolute difference" in second.display + assert second.display.endswith("PDF") + + assert not draft.set_comparison_format("jpg") + assert draft.set_comparison_format("pdf") + assert draft.get_dirty() + assert draft.comparison_plots_model.items[0].display.endswith("PDF") + assert draft.comparison_plots_model.items[1].display.endswith("PDF") + + +def test_comparison_editor_is_transient_until_explicit_commit_or_cancel() -> None: + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(_sweep_payload()) + + assert draft.begin_add_comparison() + assert draft.get_has_active_comparison_edit() + assert not draft.get_dirty() + assert not draft.get_locally_valid() + with pytest.raises(ValueError, match="Commit or cancel"): + draft.payload() + with pytest.raises(ValueError, match="invalid model sweep"): + draft.mark_baseline() + assert not draft.remove_comparison(0) + assert not draft.move_comparison(0, 0) + + active = draft.get_active_comparison_draft() + assert active is not None + active.set_name("new-comparison") + active.set_property_name("mass_density") + assert draft.commit_comparison() + assert not draft.get_has_active_comparison_edit() + assert draft.get_dirty() + assert [item["name"] for item in draft.comparison_payloads()] == ["new-comparison"] + + draft.mark_baseline() + assert draft.begin_edit_comparison(0) + active = draft.get_active_comparison_draft() + assert active is not None + active.set_name("temporary-name") + assert not draft.get_dirty() + assert draft.cancel_comparison() + assert not draft.get_has_active_comparison_edit() + assert not draft.get_dirty() + assert draft.comparison_payloads()[0]["name"] == "new-comparison" + + +def test_comparison_names_order_and_removal_are_committed_deterministically() -> None: + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(_sweep_with_comparisons()) + messages: list[str] = [] + draft.message.connect(messages.append) + + assert draft.begin_edit_comparison(0) + active = draft.get_active_comparison_draft() + assert active is not None + active.set_name("density-delta") + assert not draft.commit_comparison() + assert messages[-1] == "Comparison plot names must be unique." + assert draft.get_has_active_comparison_edit() + assert draft.cancel_comparison() + + assert draft.move_comparison(1, 0) + assert [item["name"] for item in draft.comparison_payloads()] == [ + "density-delta", + "density-comparison", + ] + assert [item["name"] for item in draft.payload()["comparison_plots"]["plots"]] == [ + "density-delta", + "density-comparison", + ] + assert draft.remove_comparison(1) + assert [item["name"] for item in draft.comparison_payloads()] == ["density-delta"] + + +def test_incompatible_committed_comparison_is_retained_clean_and_focusable() -> None: + payload = _sweep_payload() + payload["comparison_plots"] = { + "plots": [ + { + "name": "unsupported-filter", + "kind": "property_comparison", + "fluid": "Propane", + "property": "mass_density", + "x": "temperature", + "filters": {"backend_model": "pr"}, + } + ] + } + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(payload) + + assert not draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.get_first_invalid_field() == "sweep.comparison_plots" + assert draft.get_first_invalid_row() == 0 + assert draft.comparison_payloads()[0]["filters"] == {"backend_model": "pr"} + assert not draft.comparison_plots_model.items[0].compatible + + +def test_comparison_commit_rechecks_current_sweep_context() -> None: + payload = _sweep_payload() + payload["properties"].append("specific_enthalpy") + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(payload) + messages: list[str] = [] + draft.message.connect(messages.append) + + assert draft.begin_add_comparison() + active = draft.get_active_comparison_draft() + assert active is not None + active.set_name("enthalpy-comparison") + active.set_property_name("specific_enthalpy") + assert draft.dataset_draft.remove_property_value("specific_enthalpy") + + assert not draft.commit_comparison() + assert "unselected property" in messages[-1] + assert draft.get_has_active_comparison_edit() + assert draft.cancel_comparison() + + +def test_sweep_draft_clear_resets_document_state_without_capabilities() -> None: + draft = SweepDraft() + draft.apply_capabilities(_capabilities()) + draft.load_payload(_sweep_payload()) + + draft.clear() + + assert not draft.get_locally_valid() + assert not draft.get_dirty() + assert draft.get_selected_models() == [] + assert draft.get_reference_model() == "" + assert draft.comparison_plots_payload() is None + + +def test_sweep_draft_import_is_qtcore_only_and_scientifically_isolated() -> None: + code = """ +import sys +import carnopy.app.sweep_draft +for name in ( + "PySide6.QtWidgets", "CoolProp", "numpy", "pandas", "pyarrow", "matplotlib", + "carnopy.cli", "carnopy.pipeline", +): + if name in sys.modules: + raise SystemExit(name) +""" + completed = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr diff --git a/tests/test_app_worker.py b/tests/test_app_worker.py index 5dc8248..b484101 100644 --- a/tests/test_app_worker.py +++ b/tests/test_app_worker.py @@ -266,6 +266,259 @@ def test_worker_reports_structured_dataset_config_issues(property_config_path: P assert "at least 1" in issue["message"] +def test_worker_generic_load_dispatches_dataset_with_full_validation( + property_config_path: Path, +) -> None: + raw_bytes = property_config_path.read_bytes() + request_id, line = _request( + "load_configuration", + {"config_path": str(property_config_path)}, + ) + stdout = io.StringIO() + + assert main(io.StringIO(line + "\n"), stdout, io.StringIO()) == 0 + + events = _events(stdout) + assert [event["type"] for event in events] == ["accepted", "phase", "result"] + assert all(event["request_id"] == request_id for event in events) + payload = events[-1]["payload"] + assert isinstance(payload, dict) + assert payload["document_type"] == "dataset" + assert payload["config"]["document_type"] == "dataset" + assert payload["validation"]["projected_rows"] == 2 + assert payload["source_sha256"] == hashlib.sha256(raw_bytes).hexdigest() + + +def test_worker_generic_validation_accepts_expected_dataset( + property_config_path: Path, +) -> None: + yaml_text = property_config_path.read_text(encoding="utf-8") + _, line = _request( + "validate_configuration", + { + "yaml_text": yaml_text, + "source_name": "dataset.yaml", + "expected_document_type": "dataset", + }, + ) + stdout = io.StringIO() + + assert main(io.StringIO(line + "\n"), stdout, io.StringIO()) == 0 + + payload = _events(stdout)[-1]["payload"] + assert isinstance(payload, dict) + assert payload["document_type"] == "dataset" + assert payload["config"]["document_type"] == "dataset" + assert payload["validation"]["projected_rows"] == 2 + assert payload["source_sha256"] == hashlib.sha256(yaml_text.encode("utf-8")).hexdigest() + + +@pytest.mark.parametrize( + ("workflow_kind", "document_type"), + [("sweep", "model_sweep"), ("preparation", "preparation")], +) +def test_worker_generic_load_dispatches_workflow_by_document_type( + tmp_path: Path, + workflow_kind: str, + document_type: str, +) -> None: + config_path = tmp_path / f"{workflow_kind}.yaml" + raw_bytes = _stage4_config_text(workflow_kind).encode("utf-8") + config_path.write_bytes(raw_bytes) + request_id, line = _request( + "load_configuration", + {"config_path": str(config_path)}, + ) + stdout = io.StringIO() + + assert main(io.StringIO(line + "\n"), stdout, io.StringIO()) == 0 + + events = _events(stdout) + assert [event["type"] for event in events] == ["accepted", "phase", "result"] + assert all(event["request_id"] == request_id for event in events) + payload = events[-1]["payload"] + assert isinstance(payload, dict) + assert payload["document_type"] == document_type + assert payload["config"]["document_type"] == document_type + assert payload["source_name"] == str(config_path) + assert payload["source_sha256"] == hashlib.sha256(raw_bytes).hexdigest() + + +@pytest.mark.parametrize( + ("workflow_kind", "document_type"), + [("sweep", "model_sweep"), ("preparation", "preparation")], +) +def test_worker_generic_validation_accepts_expected_document_type( + workflow_kind: str, + document_type: str, +) -> None: + yaml_text = _stage4_config_text(workflow_kind) + _, line = _request( + "validate_configuration", + { + "yaml_text": yaml_text, + "source_name": f"{workflow_kind}.yaml", + "expected_document_type": document_type, + }, + ) + stdout = io.StringIO() + + assert main(io.StringIO(line + "\n"), stdout, io.StringIO()) == 0 + + payload = _events(stdout)[-1]["payload"] + assert isinstance(payload, dict) + assert payload["document_type"] == document_type + assert payload["config"]["document_type"] == document_type + assert payload["source_sha256"] == hashlib.sha256(yaml_text.encode("utf-8")).hexdigest() + + +@pytest.mark.parametrize( + ("yaml_text", "expected_document_type", "message"), + [ + ( + _stage4_config_text("sweep"), + "preparation", + "expected a preparation configuration, found model_sweep", + ), + ("schema_version: 2\n", "model_sweep", "document_type"), + ( + "schema_version: 2\ndocument_type: unknown\n", + "model_sweep", + "document_type", + ), + ("document_type: [\n", "dataset", "invalid YAML"), + ("- dataset\n", "dataset", "root must be a YAML mapping"), + ( + "schema_version: 1\nbackend: coolprop\n", + "dataset", + "schema version 1 is no longer supported", + ), + ], +) +def test_worker_generic_validation_rejects_unambiguous_dispatch_failures( + yaml_text: str, + expected_document_type: str, + message: str, +) -> None: + _, line = _request( + "validate_configuration", + { + "yaml_text": yaml_text, + "source_name": "invalid.yaml", + "expected_document_type": expected_document_type, + }, + ) + stdout = io.StringIO() + + assert main(io.StringIO(line + "\n"), stdout, io.StringIO()) == 1 + + event = _events(stdout)[-1] + assert event["type"] == "error" + payload = event["payload"] + assert isinstance(payload, dict) + assert payload["category"] == "config" + assert payload["code"] == "invalid_config" + assert message in payload["message"] + + +def test_worker_generic_validation_requires_expected_document_type() -> None: + _, line = _request( + "validate_configuration", + { + "yaml_text": _stage4_config_text("sweep"), + "source_name": "sweep.yaml", + }, + ) + stdout = io.StringIO() + + assert main(io.StringIO(line + "\n"), stdout, io.StringIO()) == 2 + + payload = _events(stdout)[-1]["payload"] + assert isinstance(payload, dict) + assert payload["category"] == "request" + assert payload["code"] == "invalid_payload" + assert payload["details"]["issues"][0]["path"] == "expected_document_type" + + +def test_worker_generic_validation_preserves_structured_schema_issues() -> None: + yaml_text = _stage4_config_text("sweep").replace( + "properties: [mass_density, specific_enthalpy]", + "properties: []", + ) + _, line = _request( + "validate_configuration", + { + "yaml_text": yaml_text, + "source_name": "invalid-sweep.yaml", + "expected_document_type": "model_sweep", + }, + ) + stdout = io.StringIO() + + assert main(io.StringIO(line + "\n"), stdout, io.StringIO()) == 1 + + payload = _events(stdout)[-1]["payload"] + assert isinstance(payload, dict) + assert payload["category"] == "config" + assert payload["code"] == "invalid_config" + assert payload["details"]["issues"] == [ + { + "path": "properties", + "code": "too_short", + "message": "List should have at least 1 item after validation, not 0", + } + ] + + +def test_worker_generic_load_reads_and_parses_once( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import carnopy.config.io as config_io + + config_path = tmp_path / "preparation.yaml" + config_path.write_text(_stage4_config_text("preparation"), encoding="utf-8") + original_read_bytes = Path.read_bytes + original_safe_load = config_io.yaml.safe_load + reads = 0 + parses = 0 + + def read_bytes(path: Path) -> bytes: + nonlocal reads + if path == config_path: + reads += 1 + return original_read_bytes(path) + + def safe_load(stream: object) -> object: + nonlocal parses + parses += 1 + return original_safe_load(stream) + + monkeypatch.setattr(Path, "read_bytes", read_bytes) + monkeypatch.setattr(config_io.yaml, "safe_load", safe_load) + _, line = _request("load_configuration", {"config_path": str(config_path)}) + + assert main(io.StringIO(line + "\n"), io.StringIO(), io.StringIO()) == 0 + assert reads == 1 + assert parses == 1 + + +def test_worker_generic_load_reports_missing_file_as_configuration_error( + tmp_path: Path, +) -> None: + missing = tmp_path / "missing.yaml" + _, line = _request("load_configuration", {"config_path": str(missing)}) + stdout = io.StringIO() + + assert main(io.StringIO(line + "\n"), stdout, io.StringIO()) == 1 + + payload = _events(stdout)[-1]["payload"] + assert isinstance(payload, dict) + assert payload["category"] == "config" + assert payload["code"] == "invalid_config" + assert "could not read configuration" in payload["message"] + + @pytest.mark.parametrize( ("workflow_kind", "request_type"), [ @@ -442,7 +695,9 @@ def test_worker_rejects_changed_config_before_pipeline_import( def test_worker_inspection_and_preview_do_not_import_coolprop(tmp_path: Path) -> None: - dataset = tmp_path / "dataset.parquet" + run = tmp_path / "dataset-run" + run.mkdir() + dataset = run / "dataset.parquet" import pandas as pd pd.DataFrame( @@ -452,6 +707,7 @@ def test_worker_inspection_and_preview_do_not_import_coolprop(tmp_path: Path) -> "mode": ["property_table"], "fluid": ["Propane"], "backend": ["coolprop"], + "backend_model": ["heos"], "backend_version": ["test"], "phase": ["gas"], "valid": [True], @@ -459,6 +715,22 @@ def test_worker_inspection_and_preview_do_not_import_coolprop(tmp_path: Path) -> "pressure_Pa": [100000.0], } ).to_parquet(dataset, index=False) + (run / "metadata.json").write_text( + json.dumps( + { + "run_id": "run", + "run_status": "completed", + "backend": "coolprop", + "backend_model": "heos", + "reference_state_policy": "coolprop_DEF", + "canonical_units": {"temperature_K": "K", "pressure_Pa": "Pa"}, + "artifact_hashes": { + "dataset.parquet": hashlib.sha256(dataset.read_bytes()).hexdigest() + }, + } + ), + encoding="utf-8", + ) code = r""" import io import json @@ -476,6 +748,7 @@ def test_worker_inspection_and_preview_do_not_import_coolprop(tmp_path: Path) -> stdout = io.StringIO() assert main(io.StringIO(inspect_request + "\n"), stdout, io.StringIO()) == 0 inspection = json.loads(stdout.getvalue().splitlines()[-1])["payload"] +assert inspection["preparation_profile"]["source_kind"] == "dataset_run" preview_request = json.dumps({ "protocol_version": 1, "request_id": "00000000-0000-0000-0000-000000000002", @@ -497,7 +770,7 @@ def test_worker_inspection_and_preview_do_not_import_coolprop(tmp_path: Path) -> """ completed = subprocess.run( - [sys.executable, "-c", code, str(dataset)], + [sys.executable, "-c", code, str(run)], capture_output=True, text=True, check=False, diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index 8ac2480..8956ddb 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -4,7 +4,7 @@ import os from collections.abc import Iterator, Mapping from pathlib import Path -from typing import cast +from typing import Any, cast from uuid import UUID import pytest @@ -15,6 +15,13 @@ from PySide6.QtCore import QCoreApplication, QEvent, QObject, Signal from carnopy.app.client import TransportOutcome, WorkerClient +from carnopy.app.config_controller import ConfigurationController +from carnopy.app.config_document import ( + ConfigurationDocument, + new_document, + serialize_configuration, + sha256_bytes, +) from carnopy.app.inspection_controller import InspectionController from carnopy.app.protocol import EventType, RequestType, WorkerEvent from carnopy.app.request_coordinator import DesktopRequestCoordinator @@ -35,7 +42,10 @@ def __init__(self) -> None: self.is_busy = False self.request_id: UUID | None = None self.request_type: RequestType | None = None + self.payload: dict[str, object] | None = None self.raise_on_start = False + self.cancelled: list[UUID] = [] + self.force_stopped: list[UUID] = [] def start_request( self, @@ -43,7 +53,6 @@ def start_request( request_type: RequestType, payload: Mapping[str, object] | None = None, ) -> None: - del payload if self.raise_on_start: raise RuntimeError("simulated start failure") if self.is_busy: @@ -51,12 +60,19 @@ def start_request( self.is_busy = True self.request_id = request_id self.request_type = request_type + self.payload = dict(payload or {}) def send_cancel(self, request_id: UUID) -> bool: - return self.is_busy and request_id == self.request_id + accepted = self.is_busy and request_id == self.request_id + if accepted: + self.cancelled.append(request_id) + return accepted def force_stop(self, request_id: UUID) -> bool: - return self.is_busy and request_id == self.request_id + accepted = self.is_busy and request_id == self.request_id + if accepted: + self.force_stopped.append(request_id) + return accepted def shutdown(self) -> None: if self.is_busy: @@ -74,6 +90,7 @@ def finish( *, payload: dict[str, object] | None = None, terminal_type: EventType = "result", + force_stopped: bool = False, ) -> None: request_id = self.request_id request_type = self.request_type @@ -94,9 +111,9 @@ def finish( terminal_event=terminal, client_failure=None, stderr="", - exit_code=0, - exit_status="normal", - force_stopped=False, + exit_code=9 if force_stopped else 0, + exit_status="crash" if force_stopped else "normal", + force_stopped=force_stopped, ) ) @@ -123,6 +140,120 @@ def _config(workspace: Workspace, name: str = "workflow.yaml") -> Path: return path +def _sweep_payload() -> dict[str, Any]: + return { + "schema_version": 2, + "document_type": "model_sweep", + "backend": { + "name": "coolprop", + "models": ["heos", "pr", "srk"], + "reference_model": "heos", + }, + "mode": "property_table", + "fluids": ["Propane"], + "grid": { + "temperature": { + "kind": "linspace", + "start": 280.0, + "stop": 340.0, + "num": 5, + "unit": "K", + }, + "pressure": { + "kind": "linspace", + "start": 1.0, + "stop": 5.0, + "num": 5, + "unit": "bar", + }, + }, + "properties": ["mass_density"], + "outputs": {"dataset_formats": ["csv", "parquet"]}, + } + + +def _sweep_capabilities() -> dict[str, Any]: + return { + "model": "heos", + "models": ["heos", "pr", "srk"], + "modes": ["property_table", "saturation_table", "vapor_mass_fraction_table"], + "units_by_axis": { + "temperature": ["K"], + "pressure": ["bar"], + "vapor_mass_fraction": ["1"], + }, + "dataset_formats": ["csv", "parquet"], + "fluids": [{"name": "Propane", "aliases": ["R290"]}], + "property_catalog": [ + { + "name": "mass_density", + "supported_models": ["heos", "pr", "srk"], + } + ], + "reference_dependent_fields": [], + "visualization": {"categorical_values": {}}, + } + + +def _saved_sweep_document(workspace: Workspace) -> ConfigurationDocument: + value = _sweep_payload() + content = serialize_configuration(value) + path = workspace.configs / "sweep.yaml" + path.write_bytes(content) + return ConfigurationDocument( + value, + source_path=path, + source_sha256=sha256_bytes(content), + workspace_owned=True, + ) + + +def _preparation_payload(*, safetensors: bool = False) -> dict[str, Any]: + outputs: dict[str, Any] = {"formats": ["parquet"]} + if safetensors: + outputs = { + "formats": ["parquet"], + "parquet": True, + "arrays": {"formats": ["safetensors"], "dtype": "float32"}, + } + return { + "schema_version": 1, + "document_type": "preparation", + "source_policy": {"allow_partial_sweep": False}, + "features": { + "numeric": ["temperature", "pressure", "mass_density"], + "derived": ["specific_volume"], + }, + "categorical_features": [ + { + "field": "phase", + "encoding": "one_hot", + "categories": "observed", + } + ], + "targets": ["specific_enthalpy"], + "auxiliary": ["fluid", "backend_model", "phase", "run_id", "case_id"], + "outputs": outputs, + } + + +def _saved_preparation_document( + workspace: Workspace, + *, + safetensors: bool = False, +) -> ConfigurationDocument: + value = _preparation_payload(safetensors=safetensors) + content = serialize_configuration(value) + path = workspace.configs / "preparation.yaml" + path.write_bytes(content) + return ConfigurationDocument( + value, + source_path=path, + source_sha256=sha256_bytes(content), + workspace_owned=True, + ) + + def _finish_load(transport: StubTransport, path: Path) -> str: digest = hashlib.sha256(path.read_bytes()).hexdigest() transport.finish( @@ -140,22 +271,190 @@ def _finish_plan( *, digest: str, plan_id: str = "b" * 64, + plan_schema_version: int | None = None, + fingerprint: dict[str, object] | None = None, source_revision: dict[str, object] | None = None, + preparation_projection: dict[str, object] | None = None, ) -> None: payload: dict[str, object] = { "plan_id": plan_id, "configuration_sha256": digest, } + if plan_schema_version is not None: + payload["plan_schema_version"] = plan_schema_version + if fingerprint is not None: + payload["fingerprint"] = fingerprint if source_revision is not None: payload["source_revision"] = source_revision + payload.update( + preparation_projection + if preparation_projection is not None + else _empty_preparation_plan_projection() + ) transport.finish(payload=payload) +def _empty_preparation_plan_projection() -> dict[str, object]: + return { + "source_row_count": 0, + "eligible_row_count": 0, + "excluded_row_count": 0, + "resolved_semantics": {}, + "reference_state": { + "selected_reference_dependent_fields": [], + "requires_context_compatibility": False, + "compatible": True, + "contexts": [], + }, + "exclusion_reason_counts": {}, + "categories": {}, + "scenarios": [], + "outputs": { + "formats": ["parquet"], + "array_feasibility": [{"scope": "table", "status": "not_requested"}], + }, + "matrix_diagnostics": None, + "baseline_feasibility": None, + "dependency_readiness": {}, + } + + +def _rich_preparation_plan_projection() -> dict[str, object]: + return { + "source_row_count": 4, + "eligible_row_count": 3, + "excluded_row_count": 1, + "resolved_semantics": { + "pressure": { + "column": "pressure", + "unit": "Pa", + "kind": "numeric", + "source": "coordinate", + }, + "specific_volume": { + "column": "specific_volume", + "unit": "m^3/kg", + "kind": "numeric", + "source": "derived", + "formula": "1 / mass_density", + "dependencies": ["mass_density"], + "reference_state_safe": True, + "array_export_allowed": True, + }, + }, + "reference_state": { + "selected_reference_dependent_fields": ["specific_enthalpy"], + "requires_context_compatibility": True, + "compatible": True, + "compatible_context": { + "reference_state_policy": "coolprop_DEF", + "backend": "coolprop", + "backend_model": "heos", + }, + "contexts": [ + { + "artifact": "dataset.parquet", + "run_id": "run", + "backend": "coolprop", + "backend_model": "heos", + "reference_state_policy": "coolprop_DEF", + "reference_state_backend_model": "heos", + "reference_state_targets": ["specific_enthalpy"], + } + ], + }, + "exclusion_reason_counts": {"missing_required_value": 1}, + "categories": {"phase": ["gas", "liquid"]}, + "scenarios": [ + { + "name": "shuffle", + "kind": "shuffle", + "partition_counts": {"train": 2, "test": 1}, + "transformations": [ + { + "field": "pressure", + "methods": ["standard"], + "output_column": "pressure__standard", + "fit_partition": "train", + "steps": [{"method": "standard", "mean": 2.0, "std": 1.0}], + } + ], + "state_leakage": { + "identity_column": "source_state_hash", + "duplicate_state_group_count": 0, + "cross_partition_group_count": 0, + }, + } + ], + "outputs": { + "formats": ["parquet", "npy"], + "array_feasibility": [ + { + "scope": "table", + "status": "ready", + "dtype": "float32", + "formats": ["npy"], + "feature_shape": [3, 2], + "target_shape": [3, 1], + "auxiliary_shapes": {}, + "float_conversion": { + "features": { + "pressure": { + "max_abs_error": 0.25, + "max_rel_error": 0.01, + "mean_abs_error": 0.1, + } + }, + "targets": {}, + }, + } + ], + }, + "matrix_diagnostics": [ + { + "scenario": "shuffle", + "fit_partition": "train", + "status": "completed", + "row_count": 2, + "feature_columns": ["pressure", "specific_volume"], + "target_columns": ["specific_enthalpy"], + "constant_feature_columns": [], + "near_constant_feature_columns": [], + "variable_feature_columns": ["pressure", "specific_volume"], + "numerical_rank": 2, + "effective_rank": 1.8, + "condition_number": 3.0, + "condition_number_is_infinite": False, + "highly_correlated_feature_pairs": [], + "feature_target_correlations": [], + } + ], + "baseline_feasibility": None, + "dependency_readiness": {"numpy": {"available": True, "version": "2.4.0"}}, + } + + +def _finish_execution( + transport: StubTransport, + output_directory: Path, + *, + run_id: str = "workflow-run", +) -> None: + transport.finish( + payload={ + "run_id": run_id, + "output_directory": str(output_directory), + "status": "completed", + } + ) + + def _accept_preparation_inspection( inspection: InspectionController, source: Path, *, revision: str, + complete_profile: bool = True, ) -> dict[str, object]: descriptor: dict[str, object] = { "source_path": str(source.resolve()), @@ -164,6 +463,98 @@ def _accept_preparation_inspection( "controls": {}, "tables": [], } + numeric_names = ( + ["temperature", "pressure", "mass_density", "specific_enthalpy"] + if complete_profile + else ["pressure", "specific_enthalpy"] + ) + numeric_candidates = [ + { + "name": name, + "column": name, + "unit": "K" if name == "temperature" else None, + "source": ("coordinate" if name in {"temperature", "pressure"} else "property"), + "reference_dependent": name == "specific_enthalpy", + } + for name in numeric_names + ] + profile: dict[str, object] = { + "profile_schema_version": 1, + "source_path": str(source.resolve()), + "source_kind": "dataset_run", + "inspection_revision": revision, + "source_identity": {"source_kind": "dataset_run"}, + "completion": { + "status": "completed", + "partial": False, + "included_child_models": [], + "missing_child_models": [], + }, + "available_models": ["heos"], + "declared_models": [], + "reference_model": "heos", + "numeric_candidates": numeric_candidates, + "target_candidates": numeric_candidates, + "categorical_candidates": [ + { + "name": "phase", + "column": "phase", + "unit": None, + "source": "categorical", + "reference_dependent": False, + } + ], + "auxiliary_candidates": [ + { + "name": name, + "column": name, + "unit": None, + "source": "auxiliary", + "reference_dependent": False, + } + for name in ("fluid", "backend_model", "phase", "run_id", "case_id") + ], + "observed_category_values": {"phase": ["gas", "liquid"]}, + "derived_features": [ + { + "name": "specific_volume", + "status": "ready" if complete_profile else "unavailable", + "available": complete_profile, + "reason": "" if complete_profile else "Density is unavailable.", + "ready_row_count": 10 if complete_profile else 0, + "source_row_count": 10, + "reason_codes": [] if complete_profile else ["missing_dependency"], + "missing_dependencies": [] if complete_profile else ["mass_density"], + "dependencies": ["mass_density"], + "unit": "m^3/kg", + } + ], + "model_holdout": { + "available": False, + "reason": "Model holdout scenarios require a model-sweep source.", + }, + "reference_context": { + "compatible": True, + "compatible_context": { + "reference_state_policy": "coolprop_DEF", + "backend": "coolprop", + "backend_model": "heos", + }, + "contexts": [ + { + "artifact": "dataset.parquet", + "run_id": "run", + "backend": "coolprop", + "backend_model": "heos", + "reference_state_policy": "coolprop_DEF", + "reference_state_backend_model": "heos", + "reference_state_targets": [], + } + ], + "reason_code": "", + "reason": "", + }, + } inspection._clear_inspection(source=source.resolve(), state="loading") inspection._accept_inspection_payload( { @@ -177,195 +568,1234 @@ def _accept_preparation_inspection( "preparation_eligible": True, "preparation_ineligible_reason": "", "preparation_source_descriptor": descriptor, + "preparation_profile": profile, } ) return descriptor -def test_sweep_controller_persists_only_execution_with_plan_identity( +def test_sweep_planning_uses_the_global_configuration_snapshot( tmp_path: Path, application: QCoreApplication, ) -> None: del application workspace = initialize_workspace(tmp_path / "workspace") - config = _config(workspace) coordinator, transport = coordinator_for() - controller = SweepWorkflowController(coordinator) + configuration = ConfigurationController(coordinator) + configuration.set_workspace(workspace) + assert transport.request_type == "describe_capabilities" + transport.finish(payload=_sweep_capabilities()) + document = _saved_sweep_document(workspace) + assert configuration.open_document(document) + + controller = SweepWorkflowController( + coordinator, + configuration_controller=configuration, + ) controller.set_workspace(workspace) + saved_bytes = document.yaml_bytes + digest = sha256_bytes(saved_bytes) - assert controller.load_config(config) - digest = _finish_load(transport, config) - assert controller.state == "ready" + assert controller.loaded_config is None + assert controller.config_path is None + assert controller.config_sha256 == "" assert controller.can_plan - + assert controller.plan_blocking_reasons.issues == () assert controller.plan() + assert transport.request_type == "plan_sweep" + assert transport.payload == { + "config_path": str(document.source_path), + "expected_config_sha256": digest, + "configs_root": str(workspace.configs), + } _finish_plan(transport, digest=digest) - assert controller.state == "planned" - assert controller.can_execute + assert controller.get_plan_current() + assert controller.can_execute + accepted_plan = controller.current_plan output = workspace.outputs / "sweep-output" - finalized: list[Path] = [] - controller.output_finalized.connect(finalized.append) + output.mkdir() assert controller.execute() - transport.finish( - payload={ - "sweep_run_id": "sweep-run", - "output_directory": str(output), - "sweep_status": "completed", - } - ) - - assert controller.state == "succeeded" - assert finalized == [output] - [record] = coordinator_for_job_records(workspace) - assert record["owner"] == "sweep" - assert record["operation"] == "execute_sweep" - assert record["plan_identity"] == { - "plan_id": "b" * 64, - "plan_schema_version": None, - "fingerprint": None, + assert transport.payload == { + "config_path": str(document.source_path), + "expected_config_sha256": digest, + "configs_root": str(workspace.configs), + "expected_plan_id": "b" * 64, + "output_root": str(workspace.outputs), } + _finish_execution(transport, output) + assert controller.get_result_relation() == "current" + + assert configuration.sweep_draft.set_reference_model("pr") + assert configuration.get_dirty() + assert controller.current_plan == accepted_plan + assert not controller.get_plan_current() + assert not controller.can_plan + assert not controller.can_execute + assert controller.get_result_relation() == "stale" + assert [issue.code for issue in controller.execution_blocking_reasons.issues] == [ + "plan_configuration_changed", + "saved_configuration_unavailable", + ] + + assert configuration.sweep_draft.set_reference_model("heos") + assert not configuration.get_dirty() + assert controller.get_plan_current() + assert controller.can_execute + assert controller.get_result_relation() == "current" + + assert configuration.sweep_draft.begin_add_comparison() + assert not controller.get_plan_current() + assert not controller.can_plan + assert not controller.can_execute + assert controller.get_result_relation() == "stale" + assert configuration.sweep_draft.cancel_comparison() + assert controller.get_plan_current() + assert controller.get_result_relation() == "current" + + dataset = _sweep_payload() + dataset["document_type"] = "dataset" + dataset["backend"] = {"name": "coolprop", "model": "heos"} + assert configuration.open_document(new_document(dataset)) + assert configuration.get_document_kind() == "dataset" + assert not controller.get_plan_current() + assert not controller.can_plan + assert controller.get_result_relation() == "unrelated" + [reason] = controller.plan_blocking_reasons.issues + assert reason.code == "saved_configuration_required" + + assert configuration.open_document(document) + assert controller.get_plan_current() + assert controller.get_result_relation() == "current" + assert controller.plan() + assert not configuration.get_can_edit() + assert configuration.sweep_draft.set_reference_model("pr") + _finish_plan(transport, digest=digest, plan_id="c" * 64) + + assert controller.state == "failed" + assert controller.failure["code"] == "stale_plan" + assert controller.current_plan == accepted_plan + assert not controller.get_plan_current() + assert configuration.sweep_draft.set_reference_model("heos") + assert controller.get_plan_current() coordinator.shutdown() -def test_failed_workflow_load_invalidates_previous_plan( +def test_preparation_planning_uses_global_configuration_and_bound_source( tmp_path: Path, application: QCoreApplication, ) -> None: del application workspace = initialize_workspace(tmp_path / "workspace") - config = _config(workspace) coordinator, transport = coordinator_for() - controller = SweepWorkflowController(coordinator) + configuration = ConfigurationController(coordinator) + configuration.set_workspace(workspace) + assert transport.request_type == "describe_capabilities" + transport.finish(payload=_sweep_capabilities()) + document = _saved_preparation_document(workspace) + assert configuration.open_document(document) + inspection = InspectionController(coordinator) + controller = PreparationWorkflowController( + coordinator, + inspection, + configuration_controller=configuration, + ) controller.set_workspace(workspace) - - assert controller.load_config(config) - digest = _finish_load(transport, config) - assert controller.plan() - _finish_plan(transport, digest=digest) - assert controller.can_execute - - assert controller.load_config(workspace.configs / "missing.yaml") - transport.finish( - terminal_type="error", - payload={ - "category": "config", - "code": "invalid_config", - "message": "configuration is invalid", - }, + source = workspace.outputs / "dataset-run" + source.mkdir() + revision = "a" * 64 + descriptor = _accept_preparation_inspection( + inspection, + source, + revision=revision, ) + assert controller.bind_inspected_source() + bound = controller.bound_source_snapshot() + assert bound is not None + assert configuration.preparation_draft.apply_source_profile(bound[3]) + digest = sha256_bytes(document.yaml_bytes) - assert controller.state == "failed" - assert controller.current_plan is None assert controller.loaded_config is None assert controller.config_path is None assert controller.config_sha256 == "" - assert controller.validation is None + assert controller.can_plan + assert controller.plan_blocking_reasons.issues == () + assert controller.plan() + assert transport.request_type == "plan_preparation" + assert transport.payload == { + "config_path": str(document.source_path), + "expected_config_sha256": digest, + "configs_root": str(workspace.configs), + "source_path": str(source.resolve()), + "inspection_revision": revision, + "inspection_descriptor": descriptor, + } + _finish_plan( + transport, + digest=digest, + source_revision={ + "inspection_revision": revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + ) + + assert controller.get_plan_current() + assert controller.can_execute + assert configuration.preparation_draft.set_allow_partial_sweep(True) + assert configuration.get_dirty() + assert not controller.get_plan_current() assert not controller.can_plan - assert not controller.can_execute + assert configuration.preparation_draft.set_allow_partial_sweep(False) + assert not configuration.get_dirty() + assert controller.get_plan_current() coordinator.shutdown() -def test_workflow_terminal_activity_persistence_failure_is_reported( +def test_preparation_planning_blocks_source_and_dependency_issues_before_worker( tmp_path: Path, application: QCoreApplication, - monkeypatch: pytest.MonkeyPatch, ) -> None: del application - from carnopy.app.jobs import JobStore - workspace = initialize_workspace(tmp_path / "workspace") - config = _config(workspace) coordinator, transport = coordinator_for() - controller = SweepWorkflowController(coordinator) + configuration = ConfigurationController(coordinator) + configuration.set_workspace(workspace) + transport.finish(payload=_sweep_capabilities()) + document = _saved_preparation_document(workspace, safetensors=True) + assert configuration.open_document(document) + inspection = InspectionController(coordinator) + controller = PreparationWorkflowController( + coordinator, + inspection, + configuration_controller=configuration, + ) controller.set_workspace(workspace) + source = workspace.outputs / "dataset-run" + source.mkdir() + first_revision = "a" * 64 + _accept_preparation_inspection( + inspection, + source, + revision=first_revision, + complete_profile=False, + ) + assert controller.bind_inspected_source() + bound = controller.bound_source_snapshot() + assert bound is not None + assert configuration.preparation_draft.apply_source_profile(bound[3]) - assert controller.load_config(config) - digest = _finish_load(transport, config) - assert controller.plan() - _finish_plan(transport, digest=digest) - assert controller.execute() - - store = controller._store - assert isinstance(store, JobStore) + assert not controller.can_plan + assert [issue.code for issue in controller.plan_blocking_reasons.issues] == [ + "preparation_source_incompatible", + "preparation_dependency_unavailable", + ] + source_issue, dependency_issue = controller.plan_blocking_reasons.issues + assert source_issue.origin == "source" + assert source_issue.field_id == "preparation.source" + assert dependency_issue.origin == "dependency" + assert dependency_issue.field_id == "preparation.outputs" + assert not controller.plan() + assert transport.request_type is None + assert controller.get_failure_code() == "plan_unavailable" + assert "unavailable in the bound source" in controller.get_failure_message() - def fail_persistence(*_args: object, **_kwargs: object) -> None: - raise OSError("disk unavailable") + second_revision = "c" * 64 + descriptor = _accept_preparation_inspection( + inspection, + source, + revision=second_revision, + ) + assert controller.bind_inspected_source() + bound = controller.bound_source_snapshot() + assert bound is not None + assert configuration.preparation_draft.apply_source_profile(bound[3]) + [reason] = controller.plan_blocking_reasons.issues + assert reason.code == "preparation_dependency_unavailable" + assert not controller.plan() + assert transport.request_type is None + assert "carnopy[ml]" in controller.get_failure_message() - monkeypatch.setattr(store, "finish", fail_persistence) - monkeypatch.setattr(store, "write", fail_persistence) - transport.finish(payload={"output_directory": str(workspace.outputs / "sweep")}) + configuration.preparation_draft.apply_capabilities( + { + "workflows": { + "preparation": { + "safetensors": {"available": True}, + "baseline_diagnostics": {"available": False}, + } + } + } + ) + assert controller.can_plan + assert controller.plan() + digest = sha256_bytes(document.yaml_bytes) + _finish_plan( + transport, + digest=digest, + source_revision={ + "inspection_revision": second_revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + ) + assert controller.can_execute - assert controller.state == "succeeded" - assert "disk unavailable" in controller.activity_persistence_issue + configuration.preparation_draft.apply_capabilities(_sweep_capabilities()) + assert not controller.can_execute + assert [issue.code for issue in controller.execution_blocking_reasons.issues] == [ + "preparation_dependency_unavailable" + ] coordinator.shutdown() -def test_worker_start_failure_does_not_leak_activity_record_into_next_request( +def test_preparation_plan_projects_typed_evidence_and_retains_it_while_stale( tmp_path: Path, application: QCoreApplication, ) -> None: del application - from carnopy.app.jobs import JobStore - workspace = initialize_workspace(tmp_path / "workspace") - config = _config(workspace) + replacement_workspace = initialize_workspace(tmp_path / "replacement-workspace") coordinator, transport = coordinator_for() - controller = SweepWorkflowController(coordinator) + configuration = ConfigurationController(coordinator) + configuration.set_workspace(workspace) + transport.finish(payload=_sweep_capabilities()) + document = _saved_preparation_document(workspace) + assert configuration.open_document(document) + inspection = InspectionController(coordinator) + controller = PreparationWorkflowController( + coordinator, + inspection, + configuration_controller=configuration, + ) controller.set_workspace(workspace) + source = workspace.outputs / "dataset-run" + source.mkdir() + revision = "a" * 64 + descriptor = _accept_preparation_inspection( + inspection, + source, + revision=revision, + ) + assert controller.bind_inspected_source() + bound = controller.bound_source_snapshot() + assert bound is not None + assert configuration.preparation_draft.apply_source_profile(bound[3]) + digest = sha256_bytes(document.yaml_bytes) - assert controller.load_config(config) - digest = _finish_load(transport, config) assert controller.plan() - _finish_plan(transport, digest=digest) + _finish_plan( + transport, + digest=digest, + source_revision={ + "inspection_revision": revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + preparation_projection=_rich_preparation_plan_projection(), + ) - transport.raise_on_start = True - assert not controller.execute() - failed_records = JobStore(workspace.private_directory).load() - [failed] = [item.data for item in failed_records if item.data is not None] - assert failed["operation"] == "execute_sweep" - assert failed["phase"] == "starting" + plan = controller.preparation_plan_model + assert plan.get_source_row_count() == 4 + assert plan.get_eligible_row_count() == 3 + assert plan.get_excluded_row_count() == 1 + assert plan.get_reference_context_required() + assert plan.get_reference_context_compatible() + assert plan.get_reference_policy() == "coolprop_DEF" + assert plan.semantic_fields.rows()[0]["name"] == "pressure" + assert plan.exclusion_reasons.rows() == ({"reason": "missing_required_value", "count": 1},) + assert plan.scenarios.get(0)["rowCount"] == 3 + assert [row["partition"] for row in plan.partitions.rows()] == [ + "train", + "test", + ] + assert plan.transformations.get(0)["fitPartition"] == "train" + assert plan.leakage_audits.get(0)["crossPartitionGroupCount"] == 0 + assert plan.array_feasibility.get(0)["featureColumns"] == 2 + assert plan.array_conversion_errors.get(0)["field"] == "pressure" + assert plan.matrix_diagnostics.get(0)["numericalRank"] == 2 + assert plan.dependencies.get(0) == { + "name": "numpy", + "available": True, + "version": "2.4.0", + } + assert controller.property("preparationPlan") is plan + assert plan.property("semanticFields") is plan.semantic_fields + accepted_plan = controller.current_plan + accepted_semantics = plan.semantic_fields.rows() - transport.raise_on_start = False - assert controller.load_config(config) - transport.emit_event("phase", {"name": "validation", "cancellable": True}) - _finish_load(transport, config) + assert configuration.preparation_draft.set_allow_partial_sweep(True) + assert not controller.get_plan_current() + assert controller.current_plan == accepted_plan + assert plan.semantic_fields.rows() == accepted_semantics + assert configuration.preparation_draft.set_allow_partial_sweep(False) + assert controller.get_plan_current() - records = JobStore(workspace.private_directory).load() - failed_after = next( - item.data - for item in records - if item.data is not None and item.data["operation"] == "execute_sweep" + assert controller.plan() + transport.finish( + payload={ + "plan_id": "c" * 64, + "configuration_sha256": digest, + "source_revision": { + "inspection_revision": revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + } ) - assert failed_after is not None - assert failed_after["phase"] == "starting" - coordinator.shutdown() - - -def coordinator_for_job_records(workspace: Workspace) -> list[dict[str, object]]: - from carnopy.app.jobs import JobStore + assert controller.get_failure_code() == "stale_plan" + assert "source row count" in controller.get_failure_message() + assert controller.current_plan == accepted_plan + assert plan.semantic_fields.rows() == accepted_semantics - records = JobStore(workspace.private_directory).load() - return [cast(dict[str, object], item.data) for item in records if item.data is not None] + controller.set_workspace(replacement_workspace) + assert controller.current_plan is None + assert plan.get_source_row_count() == 0 + assert plan.semantic_fields.rows() == () + assert plan.scenarios.rows() == () + coordinator.shutdown() -def test_preparation_controller_rejects_a_plan_for_replaced_inspection( +def test_preparation_execution_retains_global_snapshot_and_bound_source_during_edits( tmp_path: Path, application: QCoreApplication, ) -> None: del application workspace = initialize_workspace(tmp_path / "workspace") - config = _config(workspace, "preparation.yaml") - source = workspace.outputs / "dataset-run" - source.mkdir() - replacement = workspace.outputs / "replacement-run" - replacement.mkdir() coordinator, transport = coordinator_for() + configuration = ConfigurationController(coordinator) + configuration.set_workspace(workspace) + transport.finish(payload=_sweep_capabilities()) + document = _saved_preparation_document(workspace) + assert configuration.open_document(document) inspection = InspectionController(coordinator) - controller = PreparationWorkflowController(coordinator, inspection) + controller = PreparationWorkflowController( + coordinator, + inspection, + configuration_controller=configuration, + ) controller.set_workspace(workspace) - - assert controller.load_config(config) + source = workspace.outputs / "dataset-run" + source.mkdir() + revision = "a" * 64 + descriptor = _accept_preparation_inspection( + inspection, + source, + revision=revision, + ) + assert controller.bind_inspected_source() + bound = controller.bound_source_snapshot() + assert bound is not None + assert configuration.preparation_draft.apply_source_profile(bound[3]) + saved_bytes = document.yaml_bytes + digest = sha256_bytes(saved_bytes) + assert controller.plan() + _finish_plan( + transport, + digest=digest, + source_revision={ + "inspection_revision": revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + ) + + output = workspace.outputs / "preparation-output" + finalized: list[Path] = [] + controller.output_finalized.connect(finalized.append) + assert controller.execute() + expected_payload = { + "config_path": str(document.source_path), + "expected_config_sha256": digest, + "configs_root": str(workspace.configs), + "expected_plan_id": "b" * 64, + "output_root": str(workspace.outputs), + "source_path": str(source.resolve()), + "inspection_revision": revision, + "inspection_descriptor": descriptor, + } + assert transport.request_type == "execute_preparation" + assert transport.payload == expected_payload + transport.emit_event("accepted", {}) + transport.emit_event("phase", {"name": "preparation", "cancellable": True}) + transport.emit_event("progress", {"completed": 4, "total": 10}) + + replacement = workspace.outputs / "replacement-run" + replacement.mkdir() + _accept_preparation_inspection( + inspection, + replacement, + revision="c" * 64, + ) + assert configuration.get_can_edit() + assert configuration.preparation_draft.set_allow_partial_sweep(True) + assert configuration.preparation_draft.begin_add_scenario() + assert configuration.get_dirty() + assert not controller.bind_inspected_source() + assert controller.get_bound_source_path() == str(source.resolve()) + assert controller.get_operation_active() + assert transport.payload == expected_payload + + _finish_execution(transport, output, run_id="preparation-run") + + assert controller.state == "succeeded" + assert finalized == [output] + assert controller.get_result_output_directory() == str(output) + assert controller.get_result_relation() == "stale" + assert configuration.preparation_draft.get_has_active_scenario_edit() + assert controller.get_progress_completed() == 4 + assert controller.get_progress_total() == 10 + coordinator.shutdown() + + +def test_preparation_execution_uses_shared_cancel_force_and_finalization_policy( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + coordinator, transport = coordinator_for() + configuration = ConfigurationController(coordinator) + configuration.set_workspace(workspace) + transport.finish(payload=_sweep_capabilities()) + document = _saved_preparation_document(workspace) + assert configuration.open_document(document) + inspection = InspectionController(coordinator) + controller = PreparationWorkflowController( + coordinator, + inspection, + configuration_controller=configuration, + ) + controller.set_workspace(workspace) + source = workspace.outputs / "dataset-run" + source.mkdir() + revision = "a" * 64 + descriptor = _accept_preparation_inspection( + inspection, + source, + revision=revision, + ) + assert controller.bind_inspected_source() + bound = controller.bound_source_snapshot() + assert bound is not None + assert configuration.preparation_draft.apply_source_profile(bound[3]) + digest = sha256_bytes(document.yaml_bytes) + assert controller.plan() + _finish_plan( + transport, + digest=digest, + source_revision={ + "inspection_revision": revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + ) + + assert controller.execute() + request_id = transport.request_id + assert request_id is not None + transport.emit_event("accepted", {}) + transport.emit_event("phase", {"name": "preparation", "cancellable": True}) + assert controller.get_cancellation_available() + assert controller.cancel() + assert transport.cancelled == [request_id] + coordinator._enable_delayed_force_stop() + assert controller.get_force_stop_available() + assert controller.force_stop() + assert transport.force_stopped == [request_id] + transport.finish( + terminal_type="error", + payload={ + "category": "process", + "code": "force_stopped", + "message": "worker process was force-stopped", + }, + force_stopped=True, + ) + assert controller.state == "force_stopped" + + assert controller.execute() + transport.emit_event("accepted", {}) + transport.emit_event( + "phase", + { + "name": "finalization", + "cancellable": False, + "termination_protected": True, + }, + ) + assert controller.get_protected_finalization() + assert not controller.cancel() + coordinator._enable_delayed_force_stop() + assert not controller.get_force_stop_available() + assert not controller.force_stop() + _finish_execution(transport, workspace.outputs / "finalized-preparation") + assert controller.state == "succeeded" + assert not controller.get_operation_active() + coordinator.shutdown() + + +def test_sweep_execution_retains_its_global_snapshot_while_the_draft_changes( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + coordinator, transport = coordinator_for() + configuration = ConfigurationController(coordinator) + configuration.set_workspace(workspace) + transport.finish(payload=_sweep_capabilities()) + document = _saved_sweep_document(workspace) + assert configuration.open_document(document) + controller = SweepWorkflowController( + coordinator, + configuration_controller=configuration, + ) + controller.set_workspace(workspace) + saved_bytes = document.yaml_bytes + digest = sha256_bytes(saved_bytes) + assert controller.plan() + _finish_plan(transport, digest=digest) + + finalized: list[Path] = [] + controller.output_finalized.connect(finalized.append) + output = workspace.outputs / "sweep-output" + output.mkdir() + assert controller.execute() + active_request_id = transport.request_id + assert active_request_id is not None + assert transport.payload == { + "config_path": str(document.source_path), + "expected_config_sha256": digest, + "configs_root": str(workspace.configs), + "expected_plan_id": "b" * 64, + "output_root": str(workspace.outputs), + } + transport.emit_event("accepted", {}) + transport.emit_event("phase", {"name": "models", "cancellable": True}) + transport.emit_event("progress", {"completed": 1, "total": 3}) + + assert configuration.get_can_edit() + assert configuration.sweep_draft.set_reference_model("pr") + assert configuration.get_dirty() + assert controller.get_operation_active() + assert controller.get_progress_completed() == 1 + assert controller.get_progress_total() == 3 + + _finish_execution(transport, output, run_id="sweep-run") + + assert controller.state == "succeeded" + assert finalized == [output] + assert controller.get_result_output_directory() == str(output) + assert controller.get_result_relation() == "stale" + [record] = coordinator_for_job_records(workspace) + assert record["request_id"] == str(active_request_id) + assert record["status"] == "completed" + assert record["phase"] == "models" + assert record["progress"] == {"completed": 1, "total": 3} + assert record["configuration"] == { + "relative_path": "configs/sweep.yaml", + "yaml_snapshot": saved_bytes.decode("utf-8"), + "sha256": digest, + } + summary = record["summary"] + assert isinstance(summary, dict) + assert summary["output_directory"] == str(output) + coordinator.shutdown() + + +def test_sweep_execution_cancel_force_stop_and_finalization_policy_use_one_session( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + + assert controller.execute() + request_id = transport.request_id + assert request_id is not None + transport.emit_event("accepted", {}) + transport.emit_event("phase", {"name": "models", "cancellable": True}) + assert controller.get_cancellation_available() + assert controller.cancel() + assert controller.state == "cancellation_requested" + assert transport.cancelled == [request_id] + assert not controller.get_cancellation_available() + coordinator._enable_delayed_force_stop() + assert controller.get_force_stop_available() + assert controller.force_stop() + assert controller.state == "force_stopping" + assert transport.force_stopped == [request_id] + transport.finish( + terminal_type="error", + payload={ + "category": "process", + "code": "force_stopped", + "message": "worker process was force-stopped", + }, + force_stopped=True, + ) + assert controller.state == "force_stopped" + assert not controller.get_operation_active() + + assert controller.execute() + transport.emit_event("accepted", {}) + transport.emit_event( + "phase", + { + "name": "finalization", + "cancellable": False, + "termination_protected": True, + }, + ) + assert controller.get_protected_finalization() + assert not controller.cancel() + coordinator._enable_delayed_force_stop() + assert not controller.get_force_stop_available() + assert not controller.force_stop() + output = workspace.outputs / "finalized-sweep" + _finish_execution(transport, output) + assert controller.state == "succeeded" + assert controller.get_result_output_directory() == str(output) + assert sorted(str(record["status"]) for record in coordinator_for_job_records(workspace)) == [ + "completed", + "force_stopped", + ] + coordinator.shutdown() + + +def test_sweep_controller_persists_only_execution_with_plan_identity( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.state == "ready" + assert controller.can_plan + + assert controller.plan() + _finish_plan(transport, digest=digest) + assert controller.state == "planned" + assert controller.can_execute + + output = workspace.outputs / "sweep-output" + finalized: list[Path] = [] + controller.output_finalized.connect(finalized.append) + assert controller.execute() + transport.finish( + payload={ + "sweep_run_id": "sweep-run", + "output_directory": str(output), + "sweep_status": "completed", + } + ) + + assert controller.state == "succeeded" + assert finalized == [output] + assert controller.get_has_result() + assert controller.get_result_relation() == "current" + assert controller.get_result_output_directory() == str(output) + [record] = coordinator_for_job_records(workspace) + assert record["owner"] == "sweep" + assert record["operation"] == "execute_sweep" + assert record["plan_identity"] == { + "plan_id": "b" * 64, + "plan_schema_version": None, + "fingerprint": None, + } + coordinator.shutdown() + + +def test_finalized_result_survives_later_failed_and_cancelled_attempts( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert not controller.get_has_result() + assert controller.get_result_relation() == "unavailable" + assert controller.get_result_output_directory() == "" + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + + first_output = workspace.outputs / "first-output" + first_output.mkdir() + assert controller.execute() + _finish_execution(transport, first_output, run_id="first-run") + first_result = controller.result + assert controller.get_result_relation() == "current" + + assert controller.execute() + assert controller.result == first_result + assert controller.get_result_relation() == "current" + transport.finish( + terminal_type="error", + payload={ + "category": "execution", + "code": "execution_failed", + "message": "simulated later failure", + }, + ) + assert controller.state == "failed" + assert controller.result == first_result + assert controller.get_result_relation() == "current" + assert controller.get_result_output_directory() == str(first_output) + + assert controller.execute() + assert controller.result == first_result + transport.finish( + terminal_type="cancelled", + payload={"code": "cancelled", "message": "simulated cancellation"}, + ) + assert controller.state == "cancelled" + assert controller.result == first_result + assert controller.get_result_relation() == "current" + + second_output = workspace.outputs / "second-output" + second_output.mkdir() + assert controller.execute() + _finish_execution(transport, second_output, run_id="second-run") + assert controller.result != first_result + assert controller.get_result_relation() == "current" + assert controller.get_result_output_directory() == str(second_output) + coordinator.shutdown() + + +def test_sweep_result_relation_uses_saved_identity_and_clears_with_workspace( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + original_bytes = config.read_bytes() + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + output = workspace.outputs / "sweep-output" + output.mkdir() + assert controller.execute() + _finish_execution(transport, output) + finalized_result = controller.result + + config.write_text("schema_version: 2\nchanged: true\n", encoding="utf-8") + controller.state_changed.emit() + assert controller.result == finalized_result + assert controller.get_result_relation() == "stale" + + config.write_bytes(original_bytes) + assert controller.get_result_relation() == "current" + + replacement = workspace.configs / "replacement.yaml" + replacement.write_text("schema_version: 2\nchanged: true\n", encoding="utf-8") + assert controller.load_config(replacement) + _finish_load(transport, replacement) + assert controller.result == finalized_result + assert controller.get_result_relation() == "stale" + + assert controller.load_config(config) + assert _finish_load(transport, config) == digest + assert controller.result == finalized_result + assert controller.get_result_relation() == "current" + + controller.set_workspace(initialize_workspace(tmp_path / "other-workspace")) + assert controller.result is None + assert not controller.get_has_result() + assert controller.get_result_relation() == "unavailable" + assert controller.get_result_output_directory() == "" + coordinator.shutdown() + + +def test_failed_workflow_load_retains_previous_plan_as_stale( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + assert controller.can_execute + + assert controller.load_config(workspace.configs / "missing.yaml") + transport.finish( + terminal_type="error", + payload={ + "category": "config", + "code": "invalid_config", + "message": "configuration is invalid", + }, + ) + + assert controller.state == "failed" + assert controller.current_plan is not None + assert controller.get_has_plan() + assert controller.get_plan_id() == "b" * 64 + assert not controller.get_plan_current() + assert controller.loaded_config is None + assert controller.config_path is None + assert controller.config_sha256 == "" + assert controller.validation is None + assert not controller.can_plan + assert not controller.can_execute + assert [issue.code for issue in controller.execution_blocking_reasons.issues] == [ + "plan_configuration_changed", + "saved_configuration_required", + ] + coordinator.shutdown() + + +def test_workflow_load_rejects_a_result_for_another_source( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + requested = _config(workspace) + returned = workspace.configs / "returned.yaml" + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(requested) + transport.finish( + payload={ + "config": {"schema_version": 2}, + "source_name": str(returned), + "source_sha256": "a" * 64, + } + ) + + assert controller.state == "failed" + assert controller.get_failure_code() == "invalid_load_result" + assert "requested source" in controller.get_failure_message() + assert controller.loaded_config is None + assert controller.config_path is None + coordinator.shutdown() + + +def test_workflow_response_cannot_cross_a_workspace_replacement( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + replacement_workspace = initialize_workspace(tmp_path / "replacement-workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + controller.set_workspace(replacement_workspace) + _finish_load(transport, config) + + assert controller.workspace == replacement_workspace + assert controller.state == "ready" + assert controller.loaded_config is None + assert controller.config_path is None + coordinator.shutdown() + + +def test_sweep_plan_currentness_follows_exact_saved_configuration_identity( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + original_bytes = config.read_bytes() + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + + assert controller.get_has_plan() + assert controller.get_plan_id() == "b" * 64 + assert controller.get_plan_current() + assert controller.can_execute + + config.write_text("schema_version: 2\nchanged: true\n", encoding="utf-8") + controller.state_changed.emit() + assert not controller.get_plan_current() + assert not controller.can_execute + assert [issue.code for issue in controller.execution_blocking_reasons.issues] == [ + "saved_configuration_unavailable" + ] + + config.write_bytes(original_bytes) + assert controller.get_plan_current() + assert controller.can_execute + + replacement = workspace.configs / "replacement.yaml" + replacement.write_text("schema_version: 2\nchanged: true\n", encoding="utf-8") + assert controller.load_config(replacement) + replacement_digest = _finish_load(transport, replacement) + assert replacement_digest != digest + assert controller.get_has_plan() + assert not controller.get_plan_current() + assert [issue.code for issue in controller.execution_blocking_reasons.issues] == [ + "plan_configuration_changed" + ] + + assert controller.load_config(config) + assert _finish_load(transport, config) == digest + assert controller.get_plan_current() + assert controller.can_execute + + controller.set_workspace(initialize_workspace(tmp_path / "other-workspace")) + assert not controller.get_has_plan() + assert not controller.get_plan_current() + coordinator.shutdown() + + +def test_cancelled_replan_keeps_the_last_semantically_current_plan( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + accepted_plan = controller.current_plan + + assert controller.plan() + assert controller.current_plan == accepted_plan + assert controller.get_plan_current() + assert not controller.can_execute + transport.finish( + terminal_type="cancelled", + payload={"code": "cancelled", "message": "planning cancelled"}, + ) + + assert controller.state == "cancelled" + assert controller.current_plan == accepted_plan + assert controller.get_plan_current() + assert controller.can_execute + coordinator.shutdown() + + +def test_changed_saved_bytes_prevent_a_plan_response_from_replacing_the_last_plan( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + original_bytes = config.read_bytes() + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + accepted_plan = controller.current_plan + + assert controller.plan() + config.write_text("schema_version: 2\nchanged: true\n", encoding="utf-8") + _finish_plan(transport, digest=digest, plan_id="c" * 64) + + assert controller.state == "failed" + assert controller.failure["code"] == "stale_plan" + assert controller.current_plan == accepted_plan + assert controller.get_plan_id() == "b" * 64 + assert not controller.get_plan_current() + + config.write_bytes(original_bytes) + assert controller.get_plan_current() + assert controller.can_execute + coordinator.shutdown() + + +@pytest.mark.parametrize("failure_code", ["stale_plan", "source_changed"]) +def test_worker_semantic_failure_retains_but_rejects_the_plan( + tmp_path: Path, + application: QCoreApplication, + failure_code: str, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + accepted_plan = controller.current_plan + + assert controller.execute() + transport.finish( + terminal_type="error", + payload={ + "category": "config", + "code": failure_code, + "message": "execution-time planning produced a different identity", + }, + ) + + assert controller.state == "failed" + assert controller.current_plan == accepted_plan + assert controller.get_has_plan() + assert not controller.get_plan_current() + assert not controller.can_execute + [reason] = controller.execution_blocking_reasons.issues + assert reason.code == "plan_rejected_by_worker" + assert reason.origin == "plan" + assert reason.message == "execution-time planning produced a different identity" + coordinator.shutdown() + + +def test_workflow_terminal_activity_persistence_failure_is_reported( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + from carnopy.app.jobs import JobStore + + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + assert controller.execute() + + store = controller._store + assert isinstance(store, JobStore) + + def fail_persistence(*_args: object, **_kwargs: object) -> None: + raise OSError("disk unavailable") + + monkeypatch.setattr(store, "finish", fail_persistence) + monkeypatch.setattr(store, "write", fail_persistence) + transport.finish(payload={"output_directory": str(workspace.outputs / "sweep")}) + + assert controller.state == "succeeded" + assert "disk unavailable" in controller.activity_persistence_issue + coordinator.shutdown() + + +def test_worker_start_failure_does_not_leak_activity_record_into_next_request( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + from carnopy.app.jobs import JobStore + + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.plan() + _finish_plan(transport, digest=digest) + + transport.raise_on_start = True + assert not controller.execute() + assert controller._active_snapshot is None + assert controller._active_plan_context is None + assert controller._active_record is None + failed_records = JobStore(workspace.private_directory).load() + [failed] = [item.data for item in failed_records if item.data is not None] + assert failed["operation"] == "execute_sweep" + assert failed["phase"] == "starting" + + transport.raise_on_start = False + assert controller.load_config(config) + transport.emit_event("phase", {"name": "validation", "cancellable": True}) + _finish_load(transport, config) + + records = JobStore(workspace.private_directory).load() + failed_after = next( + item.data + for item in records + if item.data is not None and item.data["operation"] == "execute_sweep" + ) + assert failed_after is not None + assert failed_after["phase"] == "starting" + coordinator.shutdown() + + +def coordinator_for_job_records(workspace: Workspace) -> list[dict[str, object]]: + from carnopy.app.jobs import JobStore + + records = JobStore(workspace.private_directory).load() + return [cast(dict[str, object], item.data) for item in records if item.data is not None] + + +def test_preparation_binding_does_not_follow_inspection_and_cannot_change_during_plan( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace, "preparation.yaml") + source = workspace.outputs / "dataset-run" + source.mkdir() + replacement = workspace.outputs / "replacement-run" + replacement.mkdir() + coordinator, transport = coordinator_for() + inspection = InspectionController(coordinator) + controller = PreparationWorkflowController(coordinator, inspection) + controller.set_workspace(workspace) + + assert controller.load_config(config) digest = _finish_load(transport, config) old_revision = "a" * 64 old_descriptor = _accept_preparation_inspection( @@ -373,6 +1803,11 @@ def test_preparation_controller_rejects_a_plan_for_replaced_inspection( source, revision=old_revision, ) + assert controller.get_inspected_source_available() + assert not controller.get_can_plan() + assert controller.bind_inspected_source() + assert controller.get_bound_source_path() == str(source.resolve()) + assert controller.get_inspected_source_matches_binding() assert controller.plan() new_revision = "c" * 64 @@ -381,6 +1816,13 @@ def test_preparation_controller_rejects_a_plan_for_replaced_inspection( replacement, revision=new_revision, ) + assert controller.get_bound_source_path() == str(source.resolve()) + assert controller.get_inspected_source_available() + assert not controller.get_inspected_source_matches_binding() + assert not controller.bind_inspected_source() + assert "worker operation is active" in controller.get_source_binding_issue() + assert not controller.clear_bound_source() + assert controller.get_bound_source_path() == str(source.resolve()) _finish_plan( transport, digest=digest, @@ -391,12 +1833,443 @@ def test_preparation_controller_rejects_a_plan_for_replaced_inspection( }, ) - assert controller.state == "failed" - assert controller.failure["code"] == "stale_plan" - assert controller.current_plan is None + assert controller.state == "planned" + assert controller.get_plan_current() + assert controller.bind_inspected_source() + assert controller.current_plan is not None + assert not controller.get_plan_current() assert controller._plan_context() == { "source_path": str(replacement.resolve()), "inspection_revision": new_revision, "inspection_descriptor": new_descriptor, } coordinator.shutdown() + + +def test_preparation_plan_currentness_uses_the_complete_inspection_context( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace, "preparation.yaml") + source = workspace.outputs / "dataset-run" + source.mkdir() + replacement = workspace.outputs / "replacement-run" + replacement.mkdir() + coordinator, transport = coordinator_for() + inspection = InspectionController(coordinator) + controller = PreparationWorkflowController(coordinator, inspection) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + revision = "a" * 64 + descriptor = _accept_preparation_inspection( + inspection, + source, + revision=revision, + ) + assert controller.bind_inspected_source() + assert controller.plan() + _finish_plan( + transport, + digest=digest, + source_revision={ + "inspection_revision": revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + ) + + accepted_plan = controller.current_plan + assert controller.get_plan_current() + assert controller.can_execute + assert controller.bind_inspected_source() + assert controller.current_plan == accepted_plan + assert controller.get_plan_current() + + _accept_preparation_inspection( + inspection, + replacement, + revision=revision, + ) + assert controller.current_plan == accepted_plan + assert controller.get_plan_current() + assert controller.can_execute + assert controller.get_bound_source_path() == str(source.resolve()) + + assert controller.bind_inspected_source() + assert not controller.get_plan_current() + assert not controller.can_execute + [reason] = controller.execution_blocking_reasons.issues + assert reason.code == "plan_context_changed" + assert reason.origin == "source" + + restored_descriptor = _accept_preparation_inspection( + inspection, + source, + revision=revision, + ) + assert restored_descriptor == descriptor + assert controller.bind_inspected_source() + assert controller.current_plan == accepted_plan + assert controller.get_plan_current() + assert controller.can_execute + coordinator.shutdown() + + +def test_preparation_binding_refresh_clear_and_workspace_lifecycle_are_explicit( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + replacement_workspace = initialize_workspace(tmp_path / "replacement-workspace") + config = _config(workspace, "preparation.yaml") + source = workspace.outputs / "dataset-run" + source.mkdir() + coordinator, transport = coordinator_for() + inspection = InspectionController(coordinator) + controller = PreparationWorkflowController(coordinator, inspection) + controller.set_workspace(workspace) + + assert controller.load_config(config) + digest = _finish_load(transport, config) + original_revision = "a" * 64 + original_descriptor = _accept_preparation_inspection( + inspection, + source, + revision=original_revision, + ) + assert controller.bind_inspected_source() + snapshot = controller.bound_source_snapshot() + assert snapshot is not None + snapshot[2]["source_path"] = "mutated" + snapshot[3]["source_identity"] = {"source_kind": "mutated"} + restored_snapshot = controller.bound_source_snapshot() + assert restored_snapshot is not None + assert restored_snapshot[2] == original_descriptor + assert restored_snapshot[3]["source_identity"] == {"source_kind": "dataset_run"} + + assert controller.plan() + _finish_plan( + transport, + digest=digest, + source_revision={ + "inspection_revision": original_revision, + "inspection_descriptor": original_descriptor, + "consumed_source": {}, + }, + ) + assert controller.get_plan_current() + + refreshed_revision = "c" * 64 + refreshed_descriptor = _accept_preparation_inspection( + inspection, + source, + revision=refreshed_revision, + ) + assert controller.get_bound_source_revision() == original_revision + assert controller.get_bound_source_refresh_available() + assert controller.get_inspected_source_available() + assert controller.get_plan_current() + assert controller.config_sha256 == digest + + assert controller.bind_inspected_source() + assert controller.get_bound_source_revision() == refreshed_revision + assert not controller.get_bound_source_refresh_available() + assert not controller.get_plan_current() + assert controller._plan_context()["inspection_descriptor"] == refreshed_descriptor + assert controller.config_sha256 == digest + + assert controller.clear_bound_source() + assert not controller.get_has_bound_source() + assert controller.current_plan is not None + assert not controller.get_plan_current() + [reason] = controller.plan_blocking_reasons.issues + assert reason.code == "preparation_source_unavailable" + assert "use an inspected source" in reason.message + + assert controller.bind_inspected_source() + controller.set_workspace(replacement_workspace) + assert not controller.get_has_bound_source() + assert controller.bound_source_snapshot() is None + assert controller.current_plan is None + coordinator.shutdown() + + +def test_preparation_result_and_activity_keep_exact_execution_identities( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + coordinator, transport = coordinator_for() + configuration = ConfigurationController(coordinator) + configuration.set_workspace(workspace) + transport.finish(payload=_sweep_capabilities()) + document = _saved_preparation_document(workspace) + assert configuration.open_document(document) + saved_bytes = document.yaml_bytes + digest = sha256_bytes(saved_bytes) + source = workspace.outputs / "dataset-run" + source.mkdir() + replacement = workspace.outputs / "replacement-run" + replacement.mkdir() + inspection = InspectionController(coordinator) + controller = PreparationWorkflowController( + coordinator, + inspection, + configuration_controller=configuration, + ) + controller.set_workspace(workspace) + + revision = "a" * 64 + descriptor = _accept_preparation_inspection( + inspection, + source, + revision=revision, + ) + assert controller.bind_inspected_source() + bound = controller.bound_source_snapshot() + assert bound is not None + assert configuration.preparation_draft.apply_source_profile(bound[3]) + assert controller.plan() + fingerprint: dict[str, object] = { + "workflow_kind": "preparation", + "configuration_sha256": digest, + "runtime": {"numpy": "test-version"}, + } + _finish_plan( + transport, + digest=digest, + plan_schema_version=1, + fingerprint=fingerprint, + source_revision={ + "inspection_revision": revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + ) + + output = workspace.outputs / "preparation-output" + output.mkdir() + assert controller.execute() + request_id = transport.request_id + assert request_id is not None + transport.emit_event("accepted", {}) + transport.emit_event("phase", {"name": "preparation", "cancellable": True}) + transport.emit_event("progress", {"completed": 4, "total": 10}) + _accept_preparation_inspection( + inspection, + replacement, + revision=revision, + ) + assert not controller.bind_inspected_source() + _finish_execution(transport, output) + + assert controller.state == "succeeded" + assert controller.get_has_result() + assert controller.get_result_output_directory() == str(output) + assert controller.get_result_relation() == "current" + finalized_result = controller.result + record = next( + item + for item in coordinator_for_job_records(workspace) + if item["operation"] == "execute_preparation" and item["status"] == "completed" + ) + assert record["request_id"] == str(request_id) + assert record["owner"] == "preparation" + assert record["configuration"] == { + "relative_path": "configs/preparation.yaml", + "yaml_snapshot": saved_bytes.decode("utf-8"), + "sha256": digest, + } + assert record["plan_identity"] == { + "plan_id": "b" * 64, + "plan_schema_version": 1, + "fingerprint": fingerprint, + } + assert record["preparation_source_identity"] == { + "source_path": str(source.resolve()), + "source_kind": "dataset_run", + "inspection_revision": revision, + "descriptor": descriptor, + "source_identity": {"source_kind": "dataset_run"}, + } + assert record["phase"] == "preparation" + assert record["progress"] == {"completed": 4, "total": 10} + assert record["summary"]["output_directory"] == str(output) + + assert controller.bind_inspected_source() + assert controller.get_result_relation() == "stale" + + restored_descriptor = _accept_preparation_inspection( + inspection, + source, + revision=revision, + ) + assert restored_descriptor == descriptor + assert controller.bind_inspected_source() + assert controller.get_result_relation() == "current" + + assert controller.execute() + transport.finish( + terminal_type="error", + payload={ + "category": "execution", + "code": "execution_failed", + "message": "simulated later failure", + }, + ) + assert controller.state == "failed" + assert controller.result == finalized_result + assert controller.get_result_relation() == "current" + + assert controller.execute() + transport.finish( + terminal_type="cancelled", + payload={"code": "cancelled", "message": "simulated cancellation"}, + ) + assert controller.state == "cancelled" + assert controller.result == finalized_result + assert controller.get_result_output_directory() == str(output) + + sweep_document = _saved_sweep_document(workspace) + assert configuration.open_document(sweep_document) + assert controller.result == finalized_result + assert controller.get_result_relation() == "unrelated" + assert configuration.open_document(document) + assert controller.get_result_relation() == "current" + + controller.set_workspace(initialize_workspace(tmp_path / "replacement-workspace")) + assert controller.result is None + assert controller.get_result_relation() == "unavailable" + assert not controller.get_has_bound_source() + coordinator.shutdown() + + +def test_workflow_qml_projections_expose_existing_state_without_changing_eligibility( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + coordinator, _transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + + assert controller.get_workflow_kind() == "sweep" + assert controller.get_document_kind() == "model_sweep" + assert controller.get_workflow_state() == "unavailable" + assert not controller.get_operation_active() + assert not controller.get_can_plan() + assert not controller.get_can_execute() + assert [issue.code for issue in controller.plan_blocking_reasons.issues] == [ + "workspace_required" + ] + assert [issue.code for issue in controller.execution_blocking_reasons.issues] == [ + "current_plan_required", + "workspace_required", + ] + + controller.set_workspace(workspace) + + assert controller.get_workflow_state() == "ready" + [reason] = controller.plan_blocking_reasons.issues + assert reason.code == "saved_configuration_required" + assert reason.origin == "local" + assert reason.severity == "blocking" + assert reason.document_kind == "model_sweep" + assert reason.section == "configuration" + assert reason.field_id == "sweep.configuration" + coordinator.shutdown() + + +def test_workflow_operation_progress_and_protected_finalization_are_typed( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace) + coordinator, transport = coordinator_for() + controller = SweepWorkflowController(coordinator) + controller.set_workspace(workspace) + assert controller.load_config(config) + digest = _finish_load(transport, config) + assert controller.get_can_plan() + assert controller.plan_blocking_reasons.issues == () + assert controller.plan() + _finish_plan(transport, digest=digest) + assert controller.get_can_execute() + assert controller.execution_blocking_reasons.issues == () + + assert controller.execute() + assert controller.get_operation_active() + assert controller.get_workflow_operation() == "execute" + assert [issue.code for issue in controller.execution_blocking_reasons.issues] == [ + "operation_active" + ] + transport.emit_event("accepted", {}) + transport.emit_event( + "phase", + {"name": "generation", "cancellable": True}, + ) + transport.emit_event("progress", {"completed": 7, "total": 10}) + + assert controller.get_workflow_state() == "running" + assert controller.get_workflow_phase() == "generation" + assert controller.get_progress_available() + assert controller.get_progress_completed() == 7 + assert controller.get_progress_total() == 10 + assert controller.get_cancellation_available() + assert not controller.get_force_stop_available() + assert not controller.get_protected_finalization() + + transport.emit_event( + "phase", + { + "name": "finalization", + "cancellable": False, + "termination_protected": True, + }, + ) + + assert controller.get_protected_finalization() + assert not controller.get_cancellation_available() + assert not controller.get_force_stop_available() + transport.finish(payload={"output_directory": str(workspace.outputs / "sweep")}) + assert not controller.get_operation_active() + assert not controller.get_protected_finalization() + coordinator.shutdown() + + +def test_workflow_failure_and_preparation_source_blockers_are_typed( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + config = _config(workspace, "preparation.yaml") + coordinator, transport = coordinator_for() + inspection = InspectionController(coordinator) + controller = PreparationWorkflowController(coordinator, inspection) + controller.set_workspace(workspace) + assert controller.load_config(config) + _finish_load(transport, config) + + assert not controller.get_can_plan() + [reason] = controller.plan_blocking_reasons.issues + assert reason.code == "preparation_source_unavailable" + assert reason.origin == "source" + assert reason.document_kind == "preparation" + assert reason.section == "source" + assert reason.field_id == "preparation.source" + + assert not controller.plan() + assert controller.get_workflow_state() == "failed" + assert controller.get_failure_category() == "request" + assert controller.get_failure_code() == "plan_unavailable" + assert "use an inspected source" in controller.get_failure_message() + coordinator.shutdown() diff --git a/tests/test_app_workflow_models.py b/tests/test_app_workflow_models.py new file mode 100644 index 0000000..5163121 --- /dev/null +++ b/tests/test_app_workflow_models.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from PySide6.QtCore import Qt + +from carnopy.app.workflow_models import ( + CODE_ROLE, + DOCUMENT_KIND_ROLE, + FIELD_ID_ROLE, + ITEM_KEY_ROLE, + MESSAGE_ROLE, + NESTED_ROW_ROLE, + ORIGIN_ROLE, + PATH_ROLE, + SECTION_ROLE, + SEVERITY_ROLE, + PreparationPlanProjection, + WorkflowIssue, + WorkflowIssueModel, + WorkflowListModel, +) + + +def _preparation_plan_payload() -> dict[str, object]: + return { + "source_row_count": 10, + "eligible_row_count": 8, + "excluded_row_count": 2, + "resolved_semantics": { + "specific_volume": { + "column": "specific_volume", + "unit": "m^3/kg", + "kind": "numeric", + "source": "derived", + "formula": "1 / mass_density", + "dependencies": ["mass_density"], + "reference_state_safe": True, + "array_export_allowed": True, + }, + "pressure": { + "column": "pressure_pa", + "unit": "Pa", + "kind": "numeric", + "source": "coordinate", + }, + }, + "reference_state": { + "selected_reference_dependent_fields": ["specific_enthalpy"], + "requires_context_compatibility": True, + "compatible": True, + "compatible_context": { + "reference_state_policy": "coolprop_DEF", + "backend": "coolprop", + "backend_model": "heos", + }, + "contexts": [ + { + "artifact": "dataset.parquet", + "run_id": "run-1", + "backend": "coolprop", + "backend_model": "heos", + "reference_state_policy": "coolprop_DEF", + "reference_state_backend_model": "heos", + "reference_state_targets": ["specific_enthalpy"], + } + ], + }, + "exclusion_reason_counts": {"missing_required_value": 2}, + "categories": {"phase": ["gas", "liquid"]}, + "scenarios": [ + { + "name": "shuffle", + "kind": "shuffle", + "partition_counts": {"test": 2, "train": 6}, + "transformations": [ + { + "field": "pressure", + "methods": ["log10", "standard"], + "output_column": "pressure__log10__standard", + "fit_partition": "train", + "steps": [ + {"method": "log10"}, + {"method": "standard", "mean": 1.0, "std": 0.5}, + ], + } + ], + "state_leakage": { + "identity_column": "source_state_hash", + "duplicate_state_group_count": 1, + "cross_partition_group_count": 0, + }, + } + ], + "outputs": { + "formats": ["parquet", "npy"], + "array_feasibility": [ + { + "scope": "scenario:shuffle:train", + "status": "ready", + "dtype": "float32", + "formats": ["npy"], + "feature_shape": [6, 2], + "target_shape": [6, 1], + "auxiliary_shapes": {"fluid": [6]}, + "float_conversion": { + "features": { + "pressure": { + "max_abs_error": 0.25, + "max_rel_error": 0.01, + "mean_abs_error": 0.1, + } + } + }, + } + ], + }, + "matrix_diagnostics": [ + { + "scenario": "shuffle", + "fit_partition": "train", + "status": "completed", + "row_count": 6, + "feature_columns": ["pressure", "specific_volume"], + "target_columns": ["specific_enthalpy"], + "constant_feature_columns": [], + "near_constant_feature_columns": [], + "variable_feature_columns": ["pressure", "specific_volume"], + "numerical_rank": 2, + "effective_rank": 1.8, + "condition_number": 3.0, + "condition_number_is_infinite": False, + "highly_correlated_feature_pairs": [], + "feature_target_correlations": [ + { + "feature": "pressure", + "target": "specific_enthalpy", + "correlation": 0.75, + } + ], + } + ], + "baseline_feasibility": [ + { + "scenario": "shuffle", + "status": "ready", + "library": "scikit-learn", + "library_version": "1.8.0", + "feature_columns": ["pressure", "specific_volume"], + "target_columns": ["specific_enthalpy"], + "train_shapes": {"features": [6, 2], "targets": [6, 1]}, + "evaluation_shapes": {"test": {"features": [2, 2], "targets": [2, 1]}}, + "estimators": [ + { + "model": "ridge", + "target": "specific_enthalpy", + "estimator_type": "Pipeline", + } + ], + "fit_performed": False, + } + ], + "dependency_readiness": { + "numpy": {"available": True, "version": "2.4.0"}, + "safetensors": {"available": False, "version": None}, + }, + } + + +def test_preparation_plan_projection_flattens_worker_evidence_deterministically() -> None: + projection = PreparationPlanProjection.from_worker_payload(_preparation_plan_payload()) + + assert ( + projection.source_row_count, + projection.eligible_row_count, + projection.excluded_row_count, + ) == (10, 8, 2) + assert projection.reference_context_required + assert projection.reference_context_compatible + assert projection.reference_policy == "coolprop_DEF" + assert [row["name"] for row in projection.semantic_fields] == [ + "pressure", + "specific_volume", + ] + assert projection.reference_fields == ({"name": "specific_enthalpy"},) + assert projection.exclusion_reasons == ({"reason": "missing_required_value", "count": 2},) + assert projection.categories == ( + {"field": "phase", "value": "gas"}, + {"field": "phase", "value": "liquid"}, + ) + assert projection.scenarios[0] == { + "name": "shuffle", + "kind": "shuffle", + "order": 0, + "rowCount": 8, + "partitionCount": 2, + "transformationCount": 1, + "duplicateStateGroupCount": 1, + "crossPartitionGroupCount": 0, + } + assert [row["partition"] for row in projection.partitions] == ["train", "test"] + assert projection.transformations[0]["methods"] == ["log10", "standard"] + assert projection.leakage_audits[0]["crossPartitionGroupCount"] == 0 + assert projection.array_scopes[0]["scopeKind"] == "scenario_partition" + assert projection.array_scopes[0]["featureRows"] == 6 + assert projection.array_conversion_errors[0]["maxAbsoluteError"] == 0.25 + assert projection.array_auxiliary_shapes[0]["columnCount"] == 1 + assert projection.matrix_checks[0]["numericalRank"] == 2 + assert projection.matrix_checks[0]["featureTargetCorrelationCount"] == 1 + assert projection.baseline_checks[0]["evaluationRowCount"] == 2 + assert projection.baseline_estimators[0]["estimatorType"] == "Pipeline" + assert [row["name"] for row in projection.dependencies] == [ + "numpy", + "safetensors", + ] + + +@pytest.mark.parametrize( + ("change", "match"), + [ + ({"eligible_row_count": 7}, "row counts are inconsistent"), + ({"resolved_semantics": []}, "resolved_semantics must be"), + ({"scenarios": [{}]}, "scenario name"), + ({"matrix_diagnostics": {}}, "matrix diagnostics"), + ], +) +def test_preparation_plan_projection_rejects_malformed_worker_evidence( + change: dict[str, object], + match: str, +) -> None: + payload = _preparation_plan_payload() + payload.update(change) + + with pytest.raises(ValueError, match=match): + PreparationPlanProjection.from_worker_payload(payload) + + +def test_preparation_plan_projection_rejects_fitting_during_planning() -> None: + payload = _preparation_plan_payload() + baselines = payload["baseline_feasibility"] + assert isinstance(baselines, list) + baseline = baselines[0] + assert isinstance(baseline, dict) + baseline["fit_performed"] = True + + with pytest.raises(ValueError, match="must not report a fitted baseline"): + PreparationPlanProjection.from_worker_payload(payload) + + +def test_workflow_issue_model_exposes_stable_typed_roles() -> None: + issue = WorkflowIssue( + origin="schema", + severity="blocking", + code="invalid_filter", + message="A filter value is invalid.", + document_kind="model_sweep", + section="comparison_plots", + field_id="sweep.comparison.active.filters", + item_key="density-comparison", + nested_row=2, + path=("comparison_plots", "plots", 0, "filters", "pressure"), + ) + model = WorkflowIssueModel() + count_changes: list[None] = [] + model.count_changed.connect(lambda: count_changes.append(None)) + + assert model.replace((issue,)) + assert model.get_count() == 1 + assert count_changes == [None] + assert not model.replace((issue,)) + index = model.index(0, 0) + assert model.data(index, int(Qt.ItemDataRole.DisplayRole)) == issue.message + assert model.data(index, ORIGIN_ROLE) == "schema" + assert model.data(index, SEVERITY_ROLE) == "blocking" + assert model.data(index, CODE_ROLE) == "invalid_filter" + assert model.data(index, MESSAGE_ROLE) == issue.message + assert model.data(index, DOCUMENT_KIND_ROLE) == "model_sweep" + assert model.data(index, SECTION_ROLE) == "comparison_plots" + assert model.data(index, FIELD_ID_ROLE) == "sweep.comparison.active.filters" + assert model.data(index, ITEM_KEY_ROLE) == "density-comparison" + assert model.data(index, NESTED_ROW_ROLE) == 2 + assert model.data(index, PATH_ROLE) == [ + "comparison_plots", + "plots", + 0, + "filters", + "pressure", + ] + assert {bytes(name).decode("utf-8") for name in model.roleNames().values()} == { + "origin", + "severity", + "code", + "message", + "documentKind", + "section", + "fieldId", + "itemKey", + "nestedRow", + "path", + } + + +def test_workflow_issue_model_count_changes_only_when_length_changes() -> None: + model = WorkflowIssueModel() + changes: list[None] = [] + model.count_changed.connect(lambda: changes.append(None)) + first = WorkflowIssue( + origin="local", + severity="blocking", + code="first", + message="First", + document_kind="preparation", + section="plan", + ) + replacement = WorkflowIssue( + origin="plan", + severity="advisory", + code="replacement", + message="Replacement", + document_kind="preparation", + section="plan", + ) + + assert model.replace((first,)) + assert model.replace((replacement,)) + assert changes == [None] + assert model.issues == (replacement,) + assert model.replace(()) + assert changes == [None, None] + + +def test_workflow_list_model_exposes_only_declared_detached_roles() -> None: + model = WorkflowListModel(("name", "summary")) + count_changes: list[None] = [] + model.count_changed.connect(lambda: count_changes.append(None)) + source = {"name": "first", "summary": "Initial", "ignored": "private"} + + assert model.replace((source,)) + source["name"] = "mutated" + + assert model.get_count() == 1 + assert model.get(0) == {"name": "first", "summary": "Initial"} + assert model.rows() == ({"name": "first", "summary": "Initial"},) + assert count_changes == [None] + assert not model.replace(({"name": "first", "summary": "Initial"},)) + assert model.replace(({"name": "first", "summary": "Updated"},)) + assert count_changes == [None] + assert model.clear() + assert count_changes == [None, None] + + +def test_workflow_list_model_detaches_nested_row_values() -> None: + model = WorkflowListModel(("values",)) + source = {"values": ["original"]} + assert model.replace((source,)) + + source["values"].append("source mutation") + projected = model.get(0) + projected["values"].append("consumer mutation") + + assert model.get(0) == {"values": ["original"]} + + +def test_workflow_models_import_is_qtcore_only_and_scientifically_isolated() -> None: + code = """ +import sys +import carnopy.app.workflow_models +for name in ( + "PySide6.QtWidgets", "CoolProp", "numpy", "pandas", "pyarrow", "matplotlib", + "carnopy.cli", "carnopy.pipeline", +): + if name in sys.modules: + raise SystemExit(name) +""" + completed = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr diff --git a/tests/test_model_sweeps.py b/tests/test_model_sweeps.py index 983a312..f9bcd94 100644 --- a/tests/test_model_sweeps.py +++ b/tests/test_model_sweeps.py @@ -204,10 +204,14 @@ def test_model_sweep_comparison_plot_sidecar_records_provenance(tmp_path: Path) assert payload["selected_fluid"] == "n-Propane" assert payload["x_axis"] == "temperature" assert payload["comparison_artifact_hashes"]["comparison/values.parquet"] + assert payload["image"]["path"] == str(image) + assert ".staging" not in payload["image"]["path"] delta_image = result.comparison_plot_directory / "propane_density_delta.png" delta_sidecar = result.comparison_plot_directory / "propane_density_delta.plot.json" delta_payload = json.loads(delta_sidecar.read_text()) assert delta_image.is_file() + assert delta_payload["image"]["path"] == str(delta_image) + assert ".staging" not in delta_payload["image"]["path"] assert delta_payload["plot_kind"] == "property_delta" assert delta_payload["resolved_models"] == ["pr"] assert delta_payload["reference_model"] == "heos" @@ -223,6 +227,18 @@ def test_model_sweep_comparison_plot_sidecar_records_provenance(tmp_path: Path) assert delta_payload["metric_summary"]["count"] == len(valid_deltas) assert delta_payload["metric_summary"]["minimum"] == pytest.approx(float(valid_deltas.min())) assert delta_payload["metric_summary"]["maximum"] == pytest.approx(float(valid_deltas.max())) + assert result.comparison_report_path is not None + report = json.loads(result.comparison_report_path.read_text()) + outcomes = {outcome["name"]: outcome for outcome in report["outcomes"]} + assert outcomes["propane_density_temperature"]["image_path"] == str(image) + assert outcomes["propane_density_temperature"]["sidecar_path"] == str(sidecar) + assert outcomes["propane_density_delta"]["image_path"] == str(delta_image) + assert outcomes["propane_density_delta"]["sidecar_path"] == str(delta_sidecar) + assert all( + ".staging" not in outcome[path_key] + for outcome in outcomes.values() + for path_key in ("image_path", "sidecar_path") + ) def test_model_sweep_rejects_reference_model_in_delta_plot(tmp_path: Path) -> None: diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index f0e56a3..22ff674 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -66,10 +66,14 @@ def test_qml_runtime_is_public_and_resources_live_in_the_app_package() -> None: "qml/Carnopy/pages/ActivityPage.qml", "qml/Carnopy/pages/DatasetPage.qml", "qml/Carnopy/pages/InspectPage.qml", + "qml/Carnopy/pages/ModelSweepPage.qml", + "qml/Carnopy/pages/PreparationPage.qml", "qml/Carnopy/pages/RunPage.qml", "qml/Carnopy/pages/VisualizationPage.qml", "qml/Carnopy/pages/YamlPreviewPage.qml", "qml/Carnopy/components/BlockingBanner.qml", + "qml/Carnopy/components/ComparisonPlotEditor.qml", + "qml/Carnopy/components/PreparationScenarioEditor.qml", "qml/Carnopy/components/ActivityContextInspector.qml", "qml/Carnopy/components/InspectionContextInspector.qml", "qml/Carnopy/components/LineNumberedTextArea.qml", @@ -78,6 +82,8 @@ def test_qml_runtime_is_public_and_resources_live_in_the_app_package() -> None: "qml/Carnopy/components/PlotEditor.qml", "qml/Carnopy/components/VerifiedPlotView.qml", "qml/Carnopy/components/RunContextInspector.qml", + "qml/Carnopy/components/WorkflowContextInspector.qml", + "qml/Carnopy/components/WorkflowRunPanel.qml", "qml/Carnopy/qmldir", "resources/third-party-resources.json", "resources/branding/carnopy-mark.png", @@ -263,9 +269,12 @@ def test_public_roadmap_separates_current_contracts_from_future_direction() -> N assert "Detailed source and model candidates are maintainer planning" in normalized_readme assert "it is neither implemented nor the current product priority" in normalized_readme assert "PRODUCT_SCOPE.md" not in readme - assert "| 5 | Approved next |" in gui_plan + assert "| 5 | In progress |" in gui_plan assert "| 4 | Add controlled sweep and preparation worker operations | Complete" in desktop - assert "| 5 | Add structured sweep and preparation QML workflows | Approved next" in desktop + assert ( + "| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 19B" + in desktop + ) def test_readme_documents_published_and_source_release_boundaries() -> None: