From cb71aaf99f048eb37650aa40be4cbf6d68d4cd85 Mon Sep 17 00:00:00 2001 From: gca Date: Sun, 9 Aug 2026 21:47:13 +0200 Subject: [PATCH 01/45] refactor(app): generalize configuration document serialization --- src/carnopy/app/config_document.py | 221 ++++++++++++++++++-- src/carnopy/app/workflow_controller.py | 15 +- tests/test_app_config_document.py | 273 ++++++++++++++++++++++++- tests/test_app_execution_controller.py | 7 +- 4 files changed, 487 insertions(+), 29 deletions(-) diff --git a/src/carnopy/app/config_document.py b/src/carnopy/app/config_document.py index 0c44ac6..3b7f9ea 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,35 @@ 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, + ) + + +# Keep the dataset-only private name as a compatibility alias until the +# controller migration lands in a later independently verified unit. +DatasetConfigDocument = ConfigurationDocument + + +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 +229,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 +346,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 +482,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/workflow_controller.py b/src/carnopy/app/workflow_controller.py index f01b9f9..8efd6f3 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -7,7 +7,12 @@ from PySide6.QtCore import QObject, Signal -from carnopy.app.config_document import SavedConfigSnapshot, is_path_within, source_matches +from carnopy.app.config_document import ( + 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 @@ -440,7 +445,13 @@ 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) + document_type: DocumentType = "model_sweep" if self.kind == "sweep" else "preparation" + return SavedConfigSnapshot( + path=path, + yaml_bytes=yaml_bytes, + sha256=digest, + document_type=document_type, + ) def _plan_context(self) -> dict[str, object]: return {} diff --git a/tests/test_app_config_document.py b/tests/test_app_config_document.py index 7166b8f..e950454 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,16 +12,21 @@ import carnopy.app.config_document as config_documents from carnopy.app.config_document import ( ConfigDocumentError, + ConfigurationDocument, DatasetConfigDocument, 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 @@ -155,6 +162,230 @@ 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 isinstance(document, DatasetConfigDocument) + 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 +422,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() @@ -279,11 +531,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 +557,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_execution_controller.py b/tests/test_app_execution_controller.py index bbe2d77..99d778e 100644 --- a/tests/test_app_execution_controller.py +++ b/tests/test_app_execution_controller.py @@ -153,9 +153,10 @@ 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() From 18f5c0d6206770de5d06b27e5dddfeb47419cb27 Mon Sep 17 00:00:00 2001 From: gca Date: Sun, 9 Aug 2026 21:55:48 +0200 Subject: [PATCH 02/45] feat(app): dispatch generic configuration worker requests --- src/carnopy/app/protocol.py | 2 + src/carnopy/app/request_coordinator.py | 8 +- src/carnopy/app/worker.py | 81 ++++++++ src/carnopy/preparation/models.py | 8 + tests/test_app_request_coordinator.py | 17 ++ tests/test_app_worker.py | 253 +++++++++++++++++++++++++ 6 files changed, 368 insertions(+), 1 deletion(-) 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/request_coordinator.py b/src/carnopy/app/request_coordinator.py index 92896d4..78ca09f 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"}), 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/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/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_worker.py b/tests/test_app_worker.py index 5dc8248..d0bda77 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"), [ From 0b7078abec6fab9b4c8572659950b468d93cf6f0 Mon Sep 17 00:00:00 2001 From: gca Date: Sun, 9 Aug 2026 22:25:28 +0200 Subject: [PATCH 03/45] refactor(app): generalize configuration lifecycle under dataset behavior --- src/carnopy/app/config_controller.py | 173 ++++++++++++++++++++------- tests/test_app_config_controller.py | 107 ++++++++++++++++- 2 files changed, 238 insertions(+), 42 deletions(-) diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index 13f68c9..7913b5d 100644 --- a/src/carnopy/app/config_controller.py +++ b/src/carnopy/app/config_controller.py @@ -10,7 +10,8 @@ from carnopy.app.client import WorkerClient from carnopy.app.config_document import ( ConfigDocumentError, - DatasetConfigDocument, + ConfigurationDocument, + DocumentType, ExternalModificationError, SavedConfigSnapshot, document_from_worker_payload, @@ -32,8 +33,8 @@ from carnopy.templates import template_text -class DatasetConfigController(QObject): - """Own the complete desktop dataset-configuration workflow.""" +class ConfigurationController(QObject): + """Own one exact desktop configuration document and its file lifecycle.""" state_changed = Signal() status_message_changed = Signal() @@ -68,7 +69,7 @@ def __init__( self.dataset_draft = dataset_draft or DatasetDraft(self) self.visualization_draft = visualization_draft or VisualizationDraft(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 @@ -85,8 +86,8 @@ 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) @@ -112,6 +113,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 +132,11 @@ 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 != "dataset": + return document.needs_save + return ( document.needs_save or self.dataset_draft.get_dirty() or self.visualization_draft.get_dirty() @@ -243,7 +260,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) @@ -297,8 +320,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: @@ -371,6 +394,27 @@ 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 @@ -436,7 +480,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 +488,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,27 +509,32 @@ 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: + def open_document(self, document: ConfigurationDocument) -> bool: if not self._lifecycle_allowed("document replacement"): return False 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": + payload = document.payload + self.dataset_draft.load_payload(payload) + self.visualization_draft.set_dataset_context(payload) + self.visualization_draft.load_visualization(payload.get("visualization")) + else: + self.dataset_draft.clear() + self.visualization_draft.clear() 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() @@ -498,15 +547,27 @@ def clear_document(self, discard_confirmed: bool = False) -> bool: 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 ( + 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}" + ) + dataset_drafts_valid = expected_document_type != "dataset" or ( self.dataset_draft.get_locally_valid() and self.visualization_draft.get_locally_valid() - ): + ) + if not self._locally_valid or not dataset_drafts_valid: raise ConfigDocumentError("complete the configuration form before execution") return self.document.execution_snapshot(configs_root=self.workspace.configs) @@ -539,8 +600,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( @@ -672,7 +733,7 @@ def _apply_capabilities(self, payload: dict[str, Any]) -> None: 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: @@ -686,7 +747,7 @@ def _clear_document(self) -> None: 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() @@ -759,8 +820,9 @@ 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 == "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 +853,23 @@ 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 == "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.") @@ -942,7 +1005,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 +1024,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 [] @@ -974,3 +1061,7 @@ def _structured_issues(value: object) -> list[dict[str, str]]: if issue: issues.append(issue) return issues + + +# Retain the private Stage 2/3 name while Desktop/QML call sites migrate. +DatasetConfigController = ConfigurationController diff --git a/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index 59eb750..2f8a058 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -8,22 +8,25 @@ 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 +from carnopy.app.config_controller import ConfigurationController, DatasetConfigController from carnopy.app.config_document import ( ConfigDocumentError, DatasetConfigDocument, 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): @@ -623,3 +626,105 @@ 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_dataset_controller_name_remains_a_compatibility_alias() -> None: + assert DatasetConfigController is ConfigurationController From e55e8c15ae386b55e4b8852cffb491143e6de9c6 Mon Sep 17 00:00:00 2001 From: gca Date: Sun, 9 Aug 2026 23:01:44 +0200 Subject: [PATCH 04/45] refactor(app): migrate the shell to global configuration ownership --- src/carnopy/app/config_controller.py | 8 +-- src/carnopy/app/config_document.py | 5 -- .../app/configured_plot_results_controller.py | 4 +- src/carnopy/app/desktop_controller.py | 62 ++++++++++--------- src/carnopy/app/execution_controller.py | 6 +- src/carnopy/app/qml/Carnopy/Main.qml | 8 +-- .../app/qml/Carnopy/pages/WorkspacePage.qml | 4 +- src/carnopy/app/qml_runtime.py | 4 +- tests/test_app_config_controller.py | 19 +++--- tests/test_app_config_document.py | 8 ++- tests/test_app_desktop_controller.py | 36 ++++++----- tests/test_app_execution_controller.py | 13 ++-- tests/test_app_plot_artifacts.py | 8 +-- tests/test_app_qml_config.py | 34 +++++++--- tests/test_app_qml_dataset.py | 12 ++-- tests/test_app_qml_run.py | 4 +- tests/test_app_qml_visualization.py | 2 +- tests/test_app_qml_workspace.py | 2 +- 18 files changed, 129 insertions(+), 110 deletions(-) diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index 7913b5d..f701551 100644 --- a/src/carnopy/app/config_controller.py +++ b/src/carnopy/app/config_controller.py @@ -40,7 +40,7 @@ class ConfigurationController(QObject): 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) @@ -537,7 +537,7 @@ def open_document(self, document: ConfigurationDocument) -> bool: 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: @@ -1061,7 +1061,3 @@ def _structured_issues(value: object) -> list[dict[str, str]]: if issue: issues.append(issue) return issues - - -# Retain the private Stage 2/3 name while Desktop/QML call sites migrate. -DatasetConfigController = ConfigurationController diff --git a/src/carnopy/app/config_document.py b/src/carnopy/app/config_document.py index 3b7f9ea..dbe7db8 100644 --- a/src/carnopy/app/config_document.py +++ b/src/carnopy/app/config_document.py @@ -165,11 +165,6 @@ def execution_snapshot(self, *, configs_root: Path) -> SavedConfigSnapshot: ) -# Keep the dataset-only private name as a compatibility alias until the -# controller migration lands in a later independently verified unit. -DatasetConfigDocument = ConfigurationDocument - - def document_type_from_payload(payload: dict[str, Any]) -> DocumentType: value = payload.get("document_type") if value not in {"dataset", "model_sweep", "preparation"}: 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..589fc3d 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -6,7 +6,7 @@ from carnopy.app.activity_controller import ActivityController from carnopy.app.client import WorkerClient -from carnopy.app.config_controller import DatasetConfigController +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 @@ -37,7 +37,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) @@ -59,7 +59,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 +67,7 @@ def __init__( ) self.execution_controller = DatasetExecutionController( self.request_coordinator, - self.dataset_config_controller, + self.configuration_controller, self, ) self.inspection_controller = InspectionController( @@ -90,7 +90,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, ) @@ -128,7 +128,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_active_plot_edit) self.workspace_controller = WorkspaceController( self.request_coordinator, self.settings, @@ -142,8 +142,10 @@ 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_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) @@ -179,10 +181,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" ): @@ -267,7 +269,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 +291,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: @@ -358,12 +360,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, ) @@ -449,13 +451,13 @@ def get_dataset_decision_message(self) -> str: def request_new_dataset(self, mode: str, discard_confirmed: bool = False) -> bool: if not self._guard_active_plot_edit("New Dataset"): return False - return self.dataset_config_controller.new_dataset(mode, discard_confirmed) + return self.configuration_controller.new_dataset(mode, 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"): return False - return self.dataset_config_controller.import_dataset( + return self.configuration_controller.import_dataset( _local_path(path), discard_confirmed, ) @@ -464,19 +466,19 @@ def request_import_dataset(self, path: str, discard_confirmed: bool = False) -> def request_save(self, allow_reformat: bool = False) -> bool: if not self._guard_active_plot_edit("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"): 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"): 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: @@ -605,28 +607,28 @@ def request_activity_recovery_removal(self) -> bool: def request_save_path_selected(self, path: str) -> bool: if not self._guard_active_plot_edit("Save As"): 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) + 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"): 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"): 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: @@ -675,9 +677,9 @@ def commit_dataset_decision(self, confirmed: bool) -> bool: 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 @@ -1089,7 +1091,7 @@ def request_shutdown(self) -> bool: ) return False if ( - self.dataset_config_controller.needs_discard_confirmation() + self.configuration_controller.needs_discard_confirmation() and not self._shutdown_discard_confirmed ): self.shutdownConfirmationRequested.emit() @@ -1155,7 +1157,7 @@ def confirm_shutdown(self, discard_confirmed: bool) -> bool: 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) 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/qml/Carnopy/Main.qml b/src/carnopy/app/qml/Carnopy/Main.qml index 01820a5..9353c51 100644 --- a/src/carnopy/app/qml/Carnopy/Main.qml +++ b/src/carnopy/app/qml/Carnopy/Main.qml @@ -120,7 +120,7 @@ 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 inspectionController: controllerAvailable @@ -966,7 +966,7 @@ ApplicationWindow { id: yamlPage YamlPreviewPage { - configController: root.desktopController.datasetConfigController + configController: root.desktopController.configurationController objectName: "yamlPreviewPage" onAttentionRequested: (section, field, row) => root.configurationAttentionRequested( section, field, row) @@ -1138,8 +1138,8 @@ ApplicationWindow { datasetDecisionDialog.open(); } - function onDatasetDocumentOpened() { - root.routeTo("dataset"); + function onConfigurationDocumentOpened(documentKind) { + root.routeTo(documentKind === "dataset" ? "dataset" : "yaml"); } function onNavigationRequested(pageKey, detail) { diff --git a/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml b/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml index 02fcbce..e3e95ab 100644 --- a/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml @@ -345,7 +345,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") @@ -364,7 +364,7 @@ Item { 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_runtime.py b/src/carnopy/app/qml_runtime.py index 38f6dea..43dfdc5 100644 --- a/src/carnopy/app/qml_runtime.py +++ b/src/carnopy/app/qml_runtime.py @@ -953,7 +953,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 +965,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/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index 2f8a058..948ed7c 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -15,10 +15,11 @@ from PySide6.QtCore import QCoreApplication, QEvent, QObject, Signal -from carnopy.app.config_controller import ConfigurationController, 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, @@ -210,9 +211,9 @@ def payload(*, visualization: bool = False) -> dict[str, Any]: 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) @@ -222,6 +223,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, @@ -576,7 +581,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), @@ -724,7 +729,3 @@ def test_generic_controller_owns_non_dataset_file_lifecycle( assert controller.get_document_kind() == document_type assert controller.document is not None assert controller.document.source_path == destination.resolve() - - -def test_dataset_controller_name_remains_a_compatibility_alias() -> None: - assert DatasetConfigController is ConfigurationController diff --git a/tests/test_app_config_document.py b/tests/test_app_config_document.py index e950454..982e404 100644 --- a/tests/test_app_config_document.py +++ b/tests/test_app_config_document.py @@ -13,7 +13,6 @@ from carnopy.app.config_document import ( ConfigDocumentError, ConfigurationDocument, - DatasetConfigDocument, ExternalModificationError, document_from_worker_payload, new_document, @@ -30,6 +29,10 @@ 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"], @@ -185,7 +188,6 @@ def test_configuration_documents_serialize_deterministically_by_discriminator( assert yaml.safe_load(first)["schema_version"] == schema_version document = new_document(payload) assert isinstance(document, ConfigurationDocument) - assert isinstance(document, DatasetConfigDocument) assert document.document_type == payload["document_type"] assert document.yaml_bytes == first @@ -518,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 diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 28b9f6c..283521b 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -91,13 +91,13 @@ 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.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 @@ -118,7 +118,9 @@ 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 not hasattr(desktop, "dataset_config_controller") + assert desktop.property("datasetConfigController") is None assert desktop.property("executionController") is desktop.execution_controller assert desktop.property("activityController") is desktop.activity_controller assert ( @@ -189,7 +191,7 @@ def test_qml_shutdown_requires_explicit_dirty_discard_confirmation( desktop.shutdownConfirmationRequested.connect(lambda: confirmations.append("confirm")) desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) monkeypatch.setattr( - desktop.dataset_config_controller, + desktop.configuration_controller, "needs_discard_confirmation", lambda: True, ) @@ -523,7 +525,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 +544,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 +575,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 +598,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, ) @@ -686,7 +688,7 @@ 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, ) @@ -704,7 +706,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 == [] @@ -737,7 +739,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, ) @@ -840,12 +842,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 99d778e..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 @@ -163,7 +168,7 @@ def controller_for( 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_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_qml_config.py b/tests/test_app_qml_config.py index 377ff62..2e472d7 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,19 @@ def _process_events() -> None: application.processEvents() +def test_global_shell_routes_non_dataset_documents_to_yaml_preview( + 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") == "yaml" + + def _wait_for_idle(runtime: QmlApplicationRuntime) -> None: if not runtime.controller.request_coordinator.is_busy: _process_events() @@ -69,7 +85,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 +122,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 +154,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 +182,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 +207,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 +244,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 +261,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..0254010 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,8 @@ 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.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 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_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..13d447b 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") From 6e67df2f20b6810e1ddaeae98b3f6ed5ec3fe6c4 Mon Sep 17 00:00:00 2001 From: gca Date: Mon, 10 Aug 2026 00:13:21 +0200 Subject: [PATCH 05/45] feat(app): add structured model sweep drafts --- src/carnopy/app/sweep_draft.py | 468 +++++++++++++++++++++++++++++++++ tests/test_app_sweep_draft.py | 322 +++++++++++++++++++++++ 2 files changed, 790 insertions(+) create mode 100644 src/carnopy/app/sweep_draft.py create mode 100644 tests/test_app_sweep_draft.py diff --git a/src/carnopy/app/sweep_draft.py b/src/carnopy/app/sweep_draft.py new file mode 100644 index 0000000..1370962 --- /dev/null +++ b/src/carnopy/app/sweep_draft.py @@ -0,0 +1,468 @@ +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.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() + 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._capabilities: dict[str, Any] | None = None + self._models: tuple[str, ...] = () + self._selected_models: tuple[str, ...] = () + self._reference_model = "" + self._comparison_plots: dict[str, Any] | None = None + 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_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)}." + 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 "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 "comparison" in lowered: + return "sweep.comparison_plots" + 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: + 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 + 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 + self._comparison_plots = ( + copy.deepcopy(comparisons) if isinstance(comparisons, dict) else None + ) + 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_plots = None + 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]: + 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 self._comparison_plots is not None: + result["comparison_plots"] = copy.deepcopy(self._comparison_plots) + 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 raw_state(self) -> tuple[object, ...]: + return ( + self._loaded, + self._selected_models, + self._reference_model, + self.dataset_draft.raw_state(), + copy.deepcopy(self._comparison_plots), + ) + + def comparison_plots_payload(self) -> dict[str, Any] | None: + return copy.deepcopy(self._comparison_plots) + + @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) + + 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 + ) + + +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 diff --git a/tests/test_app_sweep_draft.py b/tests/test_app_sweep_draft.py new file mode 100644 index 0000000..43b953c --- /dev/null +++ b/tests/test_app_sweep_draft.py @@ -0,0 +1,322 @@ +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, + ) + + +@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_configuration_is_preserved_opaquely_until_its_editor_unit() -> 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_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 From cd9102922c24f8106ef9c6e0dbe1a67a4eff2c97 Mon Sep 17 00:00:00 2001 From: gca Date: Mon, 10 Aug 2026 01:08:07 +0200 Subject: [PATCH 06/45] feat(app): add structured sweep comparison drafts --- src/carnopy/app/comparison_plot_draft.py | 390 +++++++++++++++++++++++ src/carnopy/app/sweep_draft.py | 368 +++++++++++++++++++-- tests/test_app_comparison_plot_draft.py | 191 +++++++++++ tests/test_app_sweep_draft.py | 175 +++++++++- 4 files changed, 1097 insertions(+), 27 deletions(-) create mode 100644 src/carnopy/app/comparison_plot_draft.py create mode 100644 tests/test_app_comparison_plot_draft.py 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/sweep_draft.py b/src/carnopy/app/sweep_draft.py index 1370962..640a5a5 100644 --- a/src/carnopy/app/sweep_draft.py +++ b/src/carnopy/app/sweep_draft.py @@ -7,6 +7,11 @@ 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 @@ -23,17 +28,22 @@ class SweepDraft(QObject): 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_plots: dict[str, Any] | None = None + 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 @@ -55,6 +65,50 @@ def get_selected_models(self) -> list[str]: 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 @@ -108,6 +162,14 @@ def get_issue(self) -> str: 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: @@ -121,14 +183,16 @@ def get_first_invalid_field(self) -> str: 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 "comparison" in lowered: - return "sweep.comparison_plots" if "property" in lowered: return "sweep.properties" return "sweep.configuration" @@ -136,6 +200,9 @@ def get_first_invalid_field(self) -> str: 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( @@ -155,6 +222,9 @@ def get_first_invalid_row(self) -> int: 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) @@ -219,9 +289,18 @@ def load_payload(self, payload: Mapping[str, object]) -> None: self._loaded = True self._selected_models = selected_models self._reference_model = reference_model - self._comparison_plots = ( - copy.deepcopy(comparisons) if isinstance(comparisons, dict) else None - ) + 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 @@ -238,7 +317,9 @@ def clear(self) -> None: self._loaded = False self._selected_models = () self._reference_model = "" - self._comparison_plots = None + self._comparison_format = "png" + self._comparisons = () + self._discard_active_comparison() self._baseline = None self._baseline_raw = None self._refresh_models() @@ -259,23 +340,9 @@ def mark_baseline(self) -> None: self.dirty_changed.emit() def payload(self) -> 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 self._comparison_plots is not None: - result["comparison_plots"] = copy.deepcopy(self._comparison_plots) - 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) + 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 ( @@ -283,11 +350,17 @@ def raw_state(self) -> tuple[object, ...]: self._selected_models, self._reference_model, self.dataset_draft.raw_state(), - copy.deepcopy(self._comparison_plots), + self._comparison_format, + copy.deepcopy(self._comparisons), ) def comparison_plots_payload(self) -> dict[str, Any] | None: - return copy.deepcopy(self._comparison_plots) + 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: @@ -321,6 +394,214 @@ def apply_coordinate_change(self, axis: str, confirmed: bool) -> bool: 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") @@ -372,6 +653,16 @@ def _refresh_models(self) -> None: ) 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, ...]: @@ -466,3 +757,28 @@ def _find_fluid_entry( 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/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_sweep_draft.py b/tests/test_app_sweep_draft.py index 43b953c..30f1140 100644 --- a/tests/test_app_sweep_draft.py +++ b/tests/test_app_sweep_draft.py @@ -79,6 +79,36 @@ def _normalized(payload: dict[str, Any]) -> dict[str, Any]: ) +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"], @@ -254,7 +284,7 @@ def test_import_before_capabilities_remains_clean_until_rechecked() -> None: assert draft.payload() == _normalized(payload) -def test_comparison_configuration_is_preserved_opaquely_until_its_editor_unit() -> None: +def test_comparison_payload_handoff_is_detached_and_retained_when_incompatible() -> None: payload = _sweep_payload() payload["comparison_plots"] = { "format": "svg", @@ -287,6 +317,149 @@ def test_comparison_configuration_is_preserved_opaquely_until_its_editor_unit() 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()) From d7f9c008c8f49625801e9b0acbf94027dec90a93 Mon Sep 17 00:00:00 2001 From: gca Date: Mon, 10 Aug 2026 01:28:49 +0200 Subject: [PATCH 07/45] refactor(app): expose typed workflow state projections --- src/carnopy/app/request_coordinator.py | 4 + src/carnopy/app/workflow_controller.py | 277 +++++++++++++++++++++++-- src/carnopy/app/workflow_models.py | 120 +++++++++++ tests/test_app_workflow_controller.py | 125 +++++++++++ tests/test_app_workflow_models.py | 130 ++++++++++++ 5 files changed, 642 insertions(+), 14 deletions(-) create mode 100644 src/carnopy/app/workflow_models.py create mode 100644 tests/test_app_workflow_models.py diff --git a/src/carnopy/app/request_coordinator.py b/src/carnopy/app/request_coordinator.py index 78ca09f..11de7a4 100644 --- a/src/carnopy/app/request_coordinator.py +++ b/src/carnopy/app/request_coordinator.py @@ -191,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/workflow_controller.py b/src/carnopy/app/workflow_controller.py index 8efd6f3..3a07948 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any, Literal, cast -from PySide6.QtCore import QObject, Signal +from PySide6.QtCore import Property, QObject, Signal from carnopy.app.config_document import ( DocumentType, @@ -23,6 +23,7 @@ RequestReservation, RequestSession, ) +from carnopy.app.workflow_models import WorkflowIssue, WorkflowIssueModel from carnopy.app.workspace import Workspace WorkflowKind = Literal["sweep", "preparation"] @@ -64,7 +65,10 @@ def __init__( self._activity_persistence_issue = "" self._active_snapshot: SavedConfigSnapshot | 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()) + self.state_changed.connect(self._refresh_typed_projections) @property def state(self) -> str: @@ -114,34 +118,269 @@ def result(self) -> dict[str, Any] | None: 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 "model_sweep" if self.kind == "sweep" else "preparation" + + 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" + ), + ) + ) + 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_config_sha256 != self._config_sha256: + 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) + if issue := self._worker_availability_issue(): + issues.append(issue) + return tuple(issues) + + 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", + ) + 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", "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: @@ -562,6 +801,7 @@ def __init__( parent: QObject | None = None, ) -> None: super().__init__(coordinator, kind="sweep", parent=parent) + self._refresh_typed_projections() class PreparationWorkflowController(WorkflowController): @@ -575,6 +815,7 @@ def __init__( self.inspection = inspection self._planned_inspection_revision = "" inspection.inspection_changed.connect(self._inspection_changed) + self._refresh_typed_projections() def _plan_context(self) -> dict[str, object]: snapshot = self.inspection.preparation_source_snapshot() @@ -627,3 +868,11 @@ def _inspection_changed(self, _payload: object) -> None: if self._session is None or self._operation != "execute": self._invalidate_plan("inspection changed") 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 diff --git a/src/carnopy/app/workflow_models.py b/src/carnopy/app/workflow_models.py new file mode 100644 index 0000000..677ea56 --- /dev/null +++ b/src/carnopy/app/workflow_models.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from PySide6.QtCore import ( + Property, + QAbstractListModel, + QByteArray, + QModelIndex, + QObject, + QPersistentModelIndex, + Qt, + Signal, +) + +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() + + +@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/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index 8ac2480..1e04a56 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -400,3 +400,128 @@ def test_preparation_controller_rejects_a_plan_for_replaced_inspection( "inspection_descriptor": new_descriptor, } 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 "eligible preparation 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..3f5fab4 --- /dev/null +++ b/tests/test_app_workflow_models.py @@ -0,0 +1,130 @@ +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, + WorkflowIssue, + WorkflowIssueModel, +) + + +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_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 From 4e1edaa0500dada3b89248da34ac844289634f2d Mon Sep 17 00:00:00 2001 From: gca Date: Mon, 10 Aug 2026 01:37:51 +0200 Subject: [PATCH 08/45] refactor(app): track semantic workflow plan currentness --- src/carnopy/app/workflow_controller.py | 96 +++++++--- tests/test_app_workflow_controller.py | 238 ++++++++++++++++++++++++- 2 files changed, 305 insertions(+), 29 deletions(-) diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index 3a07948..2dc4ec6 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -61,6 +61,8 @@ 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._activity_persistence_issue = "" self._active_snapshot: SavedConfigSnapshot | None = None @@ -110,6 +112,31 @@ 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) @@ -282,6 +309,16 @@ def _execution_blocking_issues(self) -> tuple[WorkflowIssue, ...]: 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 self._plan_config_sha256 != self._config_sha256: issues.append( self._blocking_issue( @@ -397,7 +434,7 @@ def set_workspace(self, workspace: Workspace | None) -> None: self._config_sha256 = "" self._validation = None self._result = None - self._invalidate_plan("workspace changed") + self._clear_plan() self._set_activity_persistence_issue("") self._state = "ready" if workspace is not None else "unavailable" self.state_changed.emit() @@ -514,8 +551,6 @@ def _start( except (RuntimeError, ValueError) as exc: 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: @@ -606,8 +641,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" @@ -625,7 +663,6 @@ 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" @@ -654,18 +691,26 @@ def _accept_loaded(self, result: dict[str, object]) -> None: self._config_path = Path(source).expanduser().resolve() self._config_sha256 = digest self._validation = None - self._invalidate_plan("configuration loaded") 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: workspace = self.workspace @@ -696,10 +741,16 @@ 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 + if self._planned_context is None: + return False + try: + current_context = self._plan_context() + except ValueError: + return False + return current_context == self._planned_context def _plan_result_matches_current_context(self, _result: dict[str, object]) -> bool: return True @@ -707,9 +758,15 @@ 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_loaded_configuration(self) -> None: self._loaded_config = None @@ -813,7 +870,6 @@ def __init__( ) -> None: super().__init__(coordinator, kind="preparation", parent=parent) self.inspection = inspection - self._planned_inspection_revision = "" inspection.inspection_changed.connect(self._inspection_changed) self._refresh_typed_projections() @@ -829,14 +885,6 @@ def _plan_context(self) -> dict[str, object]: "inspection_descriptor": descriptor, } - def _record_plan_context(self) -> None: - snapshot = self.inspection.preparation_source_snapshot() - self._planned_inspection_revision = "" if snapshot is None else snapshot[1] - - 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 _plan_result_matches_current_context(self, result: dict[str, object]) -> bool: snapshot = self.inspection.preparation_source_snapshot() source_revision = result.get("source_revision") @@ -860,13 +908,7 @@ def _activity_source_identity(self) -> dict[str, Any] | None: "descriptor": descriptor, } - 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.state_changed.emit() diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index 1e04a56..94f21d2 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -228,7 +228,7 @@ def test_sweep_controller_persists_only_execution_with_plan_identity( coordinator.shutdown() -def test_failed_workflow_load_invalidates_previous_plan( +def test_failed_workflow_load_retains_previous_plan_as_stale( tmp_path: Path, application: QCoreApplication, ) -> None: @@ -256,13 +256,184 @@ def test_failed_workflow_load_invalidates_previous_plan( ) assert controller.state == "failed" - assert controller.current_plan is None + 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_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() @@ -402,6 +573,69 @@ def test_preparation_controller_rejects_a_plan_for_replaced_inspection( 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.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 + + _accept_preparation_inspection( + inspection, + replacement, + revision=revision, + ) + assert controller.current_plan == accepted_plan + 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.current_plan == accepted_plan + assert controller.get_plan_current() + assert controller.can_execute + coordinator.shutdown() + + def test_workflow_qml_projections_expose_existing_state_without_changing_eligibility( tmp_path: Path, application: QCoreApplication, From 57f3ac5058212ae7cbaf8c7a5496e10f15066e14 Mon Sep 17 00:00:00 2001 From: gca Date: Mon, 10 Aug 2026 02:14:03 +0200 Subject: [PATCH 09/45] refactor(app): retain finalized workflow results --- src/carnopy/app/workflow_controller.py | 55 ++++++- tests/test_app_workflow_controller.py | 190 +++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 4 deletions(-) diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index 2dc4ec6..f4f27b5 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -27,6 +27,7 @@ from carnopy.app.workspace import Workspace WorkflowKind = Literal["sweep", "preparation"] +ResultRelation = Literal["unavailable", "current", "stale"] class WorkflowController(QObject): @@ -64,8 +65,11 @@ def __init__( 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) @@ -141,6 +145,35 @@ def get_plan_current(self) -> bool: 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" + 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 @@ -433,7 +466,7 @@ def set_workspace(self, workspace: Workspace | None) -> None: self._config_path = None self._config_sha256 = "" self._validation = None - self._result = None + self._clear_result() self._clear_plan() self._set_activity_persistence_issue("") self._state = "ready" if workspace is not None else "unavailable" @@ -542,9 +575,11 @@ 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 + ) reservation: RequestReservation try: reservation = self.coordinator.reserve_request(self.owner, request_type) @@ -668,12 +703,16 @@ def _request_completed(self, value: object) -> None: 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_plan_context = None self._active_record = None self.state_changed.emit() @@ -744,13 +783,16 @@ def _record_plan_context(self) -> None: self._planned_context = copy.deepcopy(self._plan_context()) def _plan_context_matches(self) -> bool: - if self._planned_context is None: + 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 == self._planned_context + return current_context == expected def _plan_result_matches_current_context(self, _result: dict[str, object]) -> bool: return True @@ -768,6 +810,11 @@ def _clear_plan(self) -> None: 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_loaded_configuration(self) -> None: self._loaded_config = None self._config_path = None diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index 94f21d2..74f4035 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -151,6 +151,21 @@ def _finish_plan( transport.finish(payload=payload) +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, @@ -217,6 +232,9 @@ def test_sweep_controller_persists_only_execution_with_plan_identity( 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" @@ -228,6 +246,118 @@ def test_sweep_controller_persists_only_execution_with_plan_identity( 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, @@ -636,6 +766,66 @@ def test_preparation_plan_currentness_uses_the_complete_inspection_context( coordinator.shutdown() +def test_preparation_result_keeps_the_source_context_used_by_execution( + 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.plan() + _finish_plan( + transport, + digest=digest, + source_revision={ + "inspection_revision": revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + ) + + output = workspace.outputs / "preparation-output" + output.mkdir() + assert controller.execute() + _accept_preparation_inspection( + inspection, + replacement, + revision=revision, + ) + _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() == "stale" + + restored_descriptor = _accept_preparation_inspection( + inspection, + source, + revision=revision, + ) + assert restored_descriptor == descriptor + assert controller.get_result_relation() == "current" + coordinator.shutdown() + + def test_workflow_qml_projections_expose_existing_state_without_changing_eligibility( tmp_path: Path, application: QCoreApplication, From f8d5df91b1ad9884ba88f48862367a13885a7a69 Mon Sep 17 00:00:00 2001 From: gca Date: Mon, 10 Aug 2026 02:33:38 +0200 Subject: [PATCH 10/45] feat(app): integrate sweep document planning --- src/carnopy/app/config_controller.py | 146 +++++++++++++++---- src/carnopy/app/desktop_controller.py | 1 + src/carnopy/app/workflow_controller.py | 75 +++++++++- tests/test_app_config_controller.py | 83 +++++++++++ tests/test_app_desktop_controller.py | 9 ++ tests/test_app_workflow_controller.py | 186 ++++++++++++++++++++++++- 6 files changed, 465 insertions(+), 35 deletions(-) diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index f701551..917d6b8 100644 --- a/src/carnopy/app/config_controller.py +++ b/src/carnopy/app/config_controller.py @@ -28,6 +28,7 @@ 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 @@ -59,6 +60,8 @@ def __init__( dataset_draft: DatasetDraft | None = None, visualization_draft: VisualizationDraft | None = None, parent: QObject | None = None, + *, + sweep_draft: SweepDraft | None = None, ) -> None: super().__init__(parent) self._owns_coordinator = coordinator is None @@ -68,6 +71,7 @@ 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.workspace: Workspace | None = None self.document: ConfigurationDocument | None = None self.capabilities: dict[str, Any] | None = None @@ -92,10 +96,14 @@ def __init__( 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.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.sweep_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: @@ -134,6 +142,8 @@ def get_dirty(self) -> bool: document = self.document 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 != "dataset": return document.needs_save return ( @@ -158,7 +168,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 ) @@ -193,7 +203,13 @@ 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._locally_valid: return "none" if not self.dataset_draft.get_locally_valid(): return "dataset" @@ -205,6 +221,8 @@ 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 == "dataset": return self.dataset_draft.get_first_invalid_field() if section == "visualization": @@ -215,6 +233,8 @@ 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 == "dataset": return self.dataset_draft.get_first_invalid_row() if section == "visualization": @@ -225,6 +245,8 @@ 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 == "dataset": return self.dataset_draft.get_issue() if section == "visualization": @@ -274,7 +296,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) @@ -283,15 +305,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 == "sweep": + return bool(session.request_type == "execute_sweep") + return not self.coordinator.is_busy or self.coordinator.active_owner != "configuration" canEdit = Property(bool, get_can_edit, notify=state_changed) @@ -301,7 +326,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) @@ -334,10 +359,17 @@ 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 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_sweep_edit() or not self._lifecycle_allowed("workspace replacement") + ): return self.workspace = workspace if changed: @@ -362,12 +394,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_sweep_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 @@ -375,10 +408,20 @@ 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 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_sweep_edit(): return False if self.needs_discard_confirmation() and not discard_confirmed: self._set_status("Confirm discarding the current configuration before replacing it.") @@ -419,7 +462,12 @@ 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_sweep_edit() + ): return False if document.source_path is None or not document.workspace_owned: return self._request_save_as(allow_reformat=allow_reformat) @@ -437,7 +485,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_sweep_edit() + ): return False return self._request_save_as(allow_reformat=allow_reformat) @@ -451,6 +504,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_sweep_edit(): + return False if not self._awaiting_save_path: self._set_status("Save As is not awaiting a destination.") return False @@ -472,7 +527,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_sweep_edit() + ): return False if self.needs_discard_confirmation() and not discard_confirmed: self._set_status("Confirm discarding local changes before reloading the source.") @@ -514,20 +574,26 @@ def apply_coordinate_change(self, selected: str) -> bool: return self.dataset_draft.set_coordinate(selected) def open_document(self, document: ConfigurationDocument) -> bool: - if not self._lifecycle_allowed("document replacement"): + if self._has_active_sweep_edit() or not self._lifecycle_allowed("document replacement"): return False self.document = document self._reset_worker_validation("not_run") self._syncing_document = True try: + payload = document.payload if document.document_type == "dataset": - payload = document.payload 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() + elif document.document_type == "model_sweep": + self.dataset_draft.clear() + self.visualization_draft.clear() + self.sweep_draft.load_payload(payload) else: self.dataset_draft.clear() self.visualization_draft.clear() + self.sweep_draft.clear() finally: self._syncing_document = False label = document.document_type.replace("_", " ") @@ -541,7 +607,7 @@ def open_document(self, document: ConfigurationDocument) -> bool: return True def clear_document(self, discard_confirmed: bool = False) -> bool: - if not self._lifecycle_allowed("Close Configuration"): + if self._has_active_sweep_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.") @@ -564,10 +630,16 @@ def execution_snapshot( f"the open configuration is {self.document.document_type}, not " f"{expected_document_type}" ) - dataset_drafts_valid = expected_document_type != "dataset" or ( - self.dataset_draft.get_locally_valid() and self.visualization_draft.get_locally_valid() + drafts_valid = ( + self.sweep_draft.get_locally_valid() + if expected_document_type == "model_sweep" + else expected_document_type != "dataset" + or ( + self.dataset_draft.get_locally_valid() + and self.visualization_draft.get_locally_valid() + ) ) - if not self._locally_valid or not dataset_drafts_valid: + if not self._locally_valid or not drafts_valid or self._has_active_sweep_edit(): raise ConfigDocumentError("complete the configuration form before execution") return self.document.execution_snapshot(configs_root=self.workspace.configs) @@ -726,10 +798,19 @@ 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 _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) if self.document is not None: self._refresh_document() else: @@ -742,6 +823,7 @@ def _clear_document(self) -> None: try: self.dataset_draft.clear() self.visualization_draft.clear() + self.sweep_draft.clear() finally: self._syncing_document = False self._locally_valid = False @@ -820,7 +902,9 @@ def _finish_save(self, *, replace: bool, validation_current: bool) -> None: ) return document.mark_saved(destination, content) - if document.document_type == "dataset": + if document.document_type == "model_sweep": + self.sweep_draft.mark_baseline() + elif document.document_type == "dataset": self.dataset_draft.mark_baseline() self.visualization_draft.mark_baseline() self._file_display = str(destination) @@ -853,7 +937,11 @@ def _refresh_document(self, *, validation_revision_changed: bool = True) -> None self._emit_document_state() return try: - if document.document_type == "dataset": + 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 == "dataset": payload = self.dataset_draft.merge_into(document.payload) dataset_context = self.dataset_draft.dataset_payload() self.visualization_draft.set_dataset_context(dataset_context) @@ -916,7 +1004,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 @@ -926,10 +1014,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() ) @@ -992,6 +1080,12 @@ 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_sweep_edit() + + def _has_active_sweep_edit(self) -> bool: + return self.sweep_draft.get_has_active_comparison_edit() + def _template_payload(mode: str) -> dict[str, Any]: value = yaml.safe_load(template_text(cast(Any, mode))) diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index 589fc3d..f3b4497 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -77,6 +77,7 @@ def __init__( self.sweep_workflow_controller = SweepWorkflowController( self.request_coordinator, self, + configuration_controller=self.configuration_controller, ) self.preparation_workflow_controller = PreparationWorkflowController( self.request_coordinator, diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index f4f27b5..f4d2b33 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -3,11 +3,12 @@ import copy import hashlib from pathlib import Path -from typing import Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast from PySide6.QtCore import Property, QObject, Signal from carnopy.app.config_document import ( + ConfigDocumentError, DocumentType, SavedConfigSnapshot, is_path_within, @@ -26,8 +27,11 @@ from carnopy.app.workflow_models import 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"] +ResultRelation = Literal["unavailable", "current", "stale", "unrelated"] class WorkflowController(QObject): @@ -42,11 +46,13 @@ 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 @@ -74,6 +80,8 @@ def __init__( 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 @@ -162,6 +170,12 @@ def get_result_output_directory(self) -> str: 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: @@ -184,7 +198,7 @@ def get_workflow_kind(self) -> str: workflowKind = Property(str, get_workflow_kind, constant=True) def get_document_kind(self) -> str: - return "model_sweep" if self.kind == "sweep" else "preparation" + return self._expected_document_type() documentKind = Property(str, get_document_kind, constant=True) @@ -352,7 +366,7 @@ def _execution_blocking_issues(self) -> tuple[WorkflowIssue, ...]: field_id=f"{self.kind}.plan", ) ) - elif self._plan_config_sha256 != self._config_sha256: + elif not self._plan_configuration_matches(): issues.append( self._blocking_issue( origin="plan", @@ -389,6 +403,27 @@ def _saved_configuration_issue(self) -> WorkflowIssue | None: 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", @@ -752,6 +787,14 @@ def _accept_plan(self, result: dict[str, object]) -> None: 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 @@ -768,14 +811,25 @@ 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") - document_type: DocumentType = "model_sweep" if self.kind == "sweep" else "preparation" return SavedConfigSnapshot( path=path, yaml_bytes=yaml_bytes, sha256=digest, - document_type=document_type, + 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 {} @@ -903,8 +957,15 @@ 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() diff --git a/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index 948ed7c..80f7161 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -209,6 +209,12 @@ 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 configured_controller( tmp_path: Path, ) -> tuple[ConfigurationController, StubCoordinator]: @@ -729,3 +735,80 @@ def test_generic_controller_owns_non_dataset_file_lifecycle( 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 diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 283521b..2d45519 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -95,6 +95,7 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity 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.execution_controller.parent() is desktop assert desktop.execution_controller.coordinator is desktop.request_coordinator assert desktop.execution_controller.config_controller is desktop.configuration_controller @@ -102,6 +103,10 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity 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 @@ -119,6 +124,10 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity assert desktop.property("datasetDraft") is desktop.dataset_draft assert desktop.property("visualizationDraft") is desktop.visualization_draft assert desktop.property("configurationController") is desktop.configuration_controller + assert ( + desktop.configuration_controller.property("sweepDraft") + is desktop.configuration_controller.sweep_draft + ) assert not hasattr(desktop, "dataset_config_controller") assert desktop.property("datasetConfigController") is None assert desktop.property("executionController") is desktop.execution_controller diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index 74f4035..59ff951 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,6 +42,7 @@ 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 def start_request( @@ -43,7 +51,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,6 +58,7 @@ 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 @@ -123,6 +131,74 @@ 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 _finish_load(transport: StubTransport, path: Path) -> str: digest = hashlib.sha256(path.read_bytes()).hexdigest() transport.finish( @@ -197,6 +273,112 @@ def _accept_preparation_inspection( return descriptor +def test_sweep_planning_uses_the_global_configuration_snapshot( + 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) + 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) + digest = sha256_bytes(document.yaml_bytes) + + 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.get_plan_current() + assert controller.can_execute + accepted_plan = controller.current_plan + output = workspace.outputs / "sweep-output" + output.mkdir() + assert controller.execute() + 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_sweep_controller_persists_only_execution_with_plan_identity( tmp_path: Path, application: QCoreApplication, From 031cb4c8616bf4251ad89b34bafc68173bdd494e Mon Sep 17 00:00:00 2001 From: gca Date: Mon, 10 Aug 2026 02:50:06 +0200 Subject: [PATCH 11/45] feat(app): integrate sweep execution lifecycle --- src/carnopy/app/desktop_controller.py | 86 +++++++++++-- src/carnopy/app/workflow_controller.py | 12 +- tests/test_app_desktop_controller.py | 124 +++++++++++++++++++ tests/test_app_workflow_controller.py | 162 ++++++++++++++++++++++++- 4 files changed, 367 insertions(+), 17 deletions(-) diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index f3b4497..e669d98 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -114,6 +114,7 @@ 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.execution_controller.run_finalized.connect( lambda _path: self.inspection_controller.refresh_sources() ) @@ -379,6 +380,15 @@ 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_inspection_controller(self) -> QObject: return self.inspection_controller @@ -497,6 +507,38 @@ 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) + return False if controller is None else controller.plan() + + @Slot(str, result=bool, name="requestWorkflowExecute") + def request_workflow_execute(self, workflow: str) -> bool: + controller = self._workflow_controller(workflow) + return False if controller is None else 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)) @@ -1060,6 +1102,12 @@ 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 == "plot" and active_session.request_type == "render_plot" @@ -1112,6 +1160,10 @@ def confirm_busy_shutdown(self, confirmed: bool) -> bool: 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 == "plot" and session.request_type == "render_plot": if not self.session_plot_controller.force_stop(): self.workspace_controller.report_error( @@ -1185,19 +1237,32 @@ def _request_state_changed(self, busy: bool) -> None: 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"}: return session = self.request_coordinator.active_session + if session is None: + 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 ( - session is None - or session.owner != "execution" - or session.request_type != "generate_dataset" - or not self.execution_controller.get_can_cancel() + session.owner != "sweep" + or session.request_type != "execute_sweep" + or not self.sweep_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 = "sweep" + if not self.sweep_workflow_controller.cancel(): + self._pending_busy_shutdown = mode def _complete_busy_shutdown(self) -> None: mode, self._pending_busy_shutdown = self._pending_busy_shutdown, "" @@ -1256,6 +1321,11 @@ 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 | None: + if workflow in {"sweep", "model_sweep"}: + return self.sweep_workflow_controller + return None + def _inspect_run(self, source: str, *, navigate: bool) -> bool: if not source: self.activityActionFailed.emit( diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index f4d2b33..d0b945a 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -619,6 +619,7 @@ def _start( 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 self._set_activity_persistence_issue("") @@ -627,6 +628,7 @@ def _start( 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}" ) @@ -641,6 +643,7 @@ 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)) @@ -746,9 +749,7 @@ def _request_completed(self, value: object) -> None: if isinstance(output, str) and output: self.output_finalized.emit(Path(output)) self._session = None - self._active_snapshot = None - self._active_plan_context = None - self._active_record = None + self._clear_active_attempt() self.state_changed.emit() def _accept_loaded(self, result: dict[str, object]) -> None: @@ -869,6 +870,11 @@ def _clear_result(self) -> 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 + def _clear_loaded_configuration(self) -> None: self._loaded_config = None self._config_path = None diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 2d45519..51338fd 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -131,6 +131,7 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity 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("activityController") is desktop.activity_controller assert ( desktop.property("configuredPlotResultsController") @@ -282,6 +283,51 @@ def test_qml_shutdown_cancels_generation_then_closes_after_safe_completion( 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_plot_cleanup_failure_aborts_pending_busy_shutdown( tmp_path: Path, application: QCoreApplication, @@ -409,6 +455,84 @@ def test_execution_facade_routes_qml_intent_to_the_authoritative_controller( assert desktop.shutdown() +def test_sweep_workflow_facade_routes_only_the_integrated_workflow( + 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, + ) + + 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 not desktop.request_workflow_plan("preparation") + assert not desktop.request_workflow_execute("unknown") + assert calls == ["plan", "execute", "cancel", "force_stop"] + assert desktop.shutdown() + + +def test_sweep_result_handoff_inspects_the_exact_finalized_output( + tmp_path: Path, + application: QCoreApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del application + desktop = DesktopController(settings=settings_for(tmp_path / "settings.ini")) + output = tmp_path / "workspace" / "outputs" / "sweep-run" + inspected: list[str] = [] + navigation: list[tuple[str, str]] = [] + failures: list[tuple[str, str]] = [] + desktop.navigationRequested.connect(lambda page, detail: navigation.append((page, detail))) + desktop.activityActionFailed.connect(lambda title, message: failures.append((title, message))) + monkeypatch.setattr( + desktop.sweep_workflow_controller, + "get_result_output_directory", + lambda: str(output), + ) + monkeypatch.setattr( + desktop.inspection_controller, + "inspect_source", + lambda value: inspected.append(str(value)) or True, + ) + + assert desktop.request_workflow_inspect_result("sweep") + assert inspected == [str(output)] + assert navigation == [("inspect", "")] + assert failures == [] + + assert not desktop.request_workflow_inspect_result("preparation") + assert failures == [ + ( + "Inspect Result", + "Complete this workflow successfully before inspecting its finalized output.", + ) + ] + assert desktop.shutdown() + + def test_execution_record_changes_refresh_the_shared_activity_projection( tmp_path: Path, application: QCoreApplication, diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index 59ff951..65079ee 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -44,6 +44,8 @@ def __init__(self) -> 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, @@ -61,10 +63,16 @@ def start_request( 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: @@ -82,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 @@ -102,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, ) ) @@ -292,7 +301,8 @@ def test_sweep_planning_uses_the_global_configuration_snapshot( configuration_controller=configuration, ) controller.set_workspace(workspace) - digest = sha256_bytes(document.yaml_bytes) + saved_bytes = document.yaml_bytes + digest = sha256_bytes(saved_bytes) assert controller.loaded_config is None assert controller.config_path is None @@ -379,6 +389,143 @@ def test_sweep_planning_uses_the_global_configuration_snapshot( 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, @@ -804,6 +951,9 @@ def test_worker_start_failure_does_not_leak_activity_record_into_next_request( 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" From 607849cd233797b0ebc94cd00ca0dfa0d18918fb Mon Sep 17 00:00:00 2001 From: gca Date: Mon, 10 Aug 2026 04:12:44 +0200 Subject: [PATCH 12/45] feat(app): add the structured sweep editor page --- scripts/check_distribution.py | 3 + src/carnopy/app/desktop_controller.py | 245 +++++++ .../components/ComparisonPlotEditor.qml | 387 +++++++++++ .../Carnopy/components/WorkflowRunPanel.qml | 263 +++++++ .../app/qml/Carnopy/pages/ModelSweepPage.qml | 653 ++++++++++++++++++ src/carnopy/app/qml/Carnopy/qmldir | 3 + src/carnopy/app/qml_resources.py | 3 + tests/test_app_desktop_controller.py | 38 + tests/test_app_qml_runtime.py | 2 +- tests/test_app_qml_sweep.py | 254 +++++++ tests/test_packaging_metadata.py | 3 + 11 files changed, 1853 insertions(+), 1 deletion(-) create mode 100644 src/carnopy/app/qml/Carnopy/components/ComparisonPlotEditor.qml create mode 100644 src/carnopy/app/qml/Carnopy/components/WorkflowRunPanel.qml create mode 100644 src/carnopy/app/qml/Carnopy/pages/ModelSweepPage.qml create mode 100644 tests/test_app_qml_sweep.py diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py index b1a975b..2020d50 100644 --- a/scripts/check_distribution.py +++ b/scripts/check_distribution.py @@ -48,6 +48,7 @@ "qml/Carnopy/components/ChoiceList.qml", "qml/Carnopy/components/SearchableChoiceList.qml", "qml/Carnopy/components/CommandBar.qml", + "qml/Carnopy/components/ComparisonPlotEditor.qml", "qml/Carnopy/components/ContextInspector.qml", "qml/Carnopy/components/ActivityContextInspector.qml", "qml/Carnopy/components/InspectionContextInspector.qml", @@ -65,12 +66,14 @@ "qml/Carnopy/components/StatusBadge.qml", "qml/Carnopy/components/ToastHost.qml", "qml/Carnopy/components/ValidationIssue.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/RunPage.qml", "qml/Carnopy/pages/SettingsPage.qml", "qml/Carnopy/pages/WorkspacePage.qml", diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index e669d98..952e88f 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -6,6 +6,7 @@ from carnopy.app.activity_controller import ActivityController from carnopy.app.client import WorkerClient +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 @@ -792,6 +793,218 @@ def request_dataset_sampler_unit_change(self, candidate: QObject, unit: str) -> if sampler is not None: 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(bool, name="requestVisualizationEnabled") def request_visualization_enabled(self, enabled: bool) -> None: if self._guard_active_plot_edit("visualization enable or disable"): @@ -962,6 +1175,38 @@ def _owned_dataset_sampler(self, candidate: QObject) -> SamplerDraft | None: None, ) + def _can_edit_sweep_document(self) -> bool: + if self.configuration_controller.get_document_kind() != "model_sweep": + return False + if self.configuration_controller.get_can_edit(): + return True + self.workspace_controller.report_error( + "Wait for the active worker request before editing the Model Sweep configuration." + ) + 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_active_plot(self, candidate: QObject) -> PlotDraft | None: active_drafts = ( self.visualization_draft.get_active_plot_draft(), 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/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/ModelSweepPage.qml b/src/carnopy/app/qml/Carnopy/pages/ModelSweepPage.qml new file mode 100644 index 0000000..a54215c --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/pages/ModelSweepPage.qml @@ -0,0 +1,653 @@ +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 locked: !configController.canEdit + + signal shapeDialogRequested + + 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.sweepDraft.hasActiveComparisonEdit ? qsTr("Comparison edit open") : ( + root.sweepDraft.locallyValid + ? qsTr("Locally complete") : + qsTr("Needs attention")) + objectName: "modelSweepLocalState" + tone: root.sweepDraft.hasActiveComparisonEdit ? "warning" : ( + root.sweepDraft.locallyValid + ? "success" : "danger") + } + } + + 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.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 + + 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") + + 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/qmldir b/src/carnopy/app/qml/Carnopy/qmldir index f628523..785e9f5 100644 --- a/src/carnopy/app/qml/Carnopy/qmldir +++ b/src/carnopy/app/qml/Carnopy/qmldir @@ -8,6 +8,7 @@ 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 ContextInspector 1.0 components/ContextInspector.qml RunContextInspector 1.0 components/RunContextInspector.qml InspectionContextInspector 1.0 components/InspectionContextInspector.qml @@ -28,6 +29,7 @@ ChoiceList 1.0 components/ChoiceList.qml SearchableChoiceList 1.0 components/SearchableChoiceList.qml SamplerEditor 1.0 components/SamplerEditor.qml ValidationIssue 1.0 components/ValidationIssue.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 +40,4 @@ 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 diff --git a/src/carnopy/app/qml_resources.py b/src/carnopy/app/qml_resources.py index cda2a9b..5a0edf0 100644 --- a/src/carnopy/app/qml_resources.py +++ b/src/carnopy/app/qml_resources.py @@ -34,6 +34,7 @@ "qml/Carnopy/components/ChoiceList.qml", "qml/Carnopy/components/SearchableChoiceList.qml", "qml/Carnopy/components/CommandBar.qml", + "qml/Carnopy/components/ComparisonPlotEditor.qml", "qml/Carnopy/components/ContextInspector.qml", "qml/Carnopy/components/InspectionContextInspector.qml", "qml/Carnopy/components/ActivityContextInspector.qml", @@ -49,11 +50,13 @@ "qml/Carnopy/components/StatusBadge.qml", "qml/Carnopy/components/ToastHost.qml", "qml/Carnopy/components/ValidationIssue.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/ActivityPage.qml", "qml/Carnopy/pages/SettingsPage.qml", "qml/Carnopy/pages/WorkspacePage.qml", diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 51338fd..fbb1bdc 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -494,6 +494,44 @@ def test_sweep_workflow_facade_routes_only_the_integrated_workflow( assert desktop.shutdown() +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")) + 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) + + desktop.request_sweep_model_selection("pr", True) + assert selections == [] + + 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_sweep_result_handoff_inspects_the_exact_finalized_output( tmp_path: Path, application: QCoreApplication, diff --git a/tests/test_app_qml_runtime.py b/tests/test_app_qml_runtime.py index 45ccffb..dc6ae6e 100644 --- a/tests/test_app_qml_runtime.py +++ b/tests/test_app_qml_runtime.py @@ -642,4 +642,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 42 file(s).\n" diff --git a/tests/test_app_qml_sweep.py b/tests/test_app_qml_sweep.py new file mode 100644 index 0000000..c5b46e7 --- /dev/null +++ b/tests/test_app_qml_sweep.py @@ -0,0 +1,254 @@ +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.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] + + +@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 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") + 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 "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 "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() diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index f0e56a3..8582efc 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -66,10 +66,12 @@ 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/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/ActivityContextInspector.qml", "qml/Carnopy/components/InspectionContextInspector.qml", "qml/Carnopy/components/LineNumberedTextArea.qml", @@ -78,6 +80,7 @@ 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/WorkflowRunPanel.qml", "qml/Carnopy/qmldir", "resources/third-party-resources.json", "resources/branding/carnopy-mark.png", From 46a20aa11c0884545217096e6afab0bf7d709ef9 Mon Sep 17 00:00:00 2001 From: gca Date: Tue, 11 Aug 2026 02:55:32 +0200 Subject: [PATCH 13/45] feat(app): enable the sweep workflow surface --- scripts/check_distribution.py | 1 + src/carnopy/app/desktop_controller.py | 43 ++- src/carnopy/app/qml/Carnopy/Main.qml | 183 +++++++++--- .../Carnopy/components/ContextInspector.qml | 25 +- .../app/qml/Carnopy/components/NavRail.qml | 9 +- .../components/WorkflowContextInspector.qml | 266 ++++++++++++++++++ .../app/qml/Carnopy/pages/ModelSweepPage.qml | 41 ++- .../app/qml/Carnopy/pages/WorkspacePage.qml | 34 ++- src/carnopy/app/qml/Carnopy/qmldir | 1 + src/carnopy/app/qml_resources.py | 1 + src/carnopy/app/qml_runtime.py | 6 +- tests/test_app_desktop_controller.py | 66 +++++ tests/test_app_qml_config.py | 5 +- tests/test_app_qml_dataset.py | 4 +- tests/test_app_qml_runtime.py | 2 +- tests/test_app_qml_shell.py | 6 +- tests/test_app_qml_sweep.py | 144 +++++++++- tests/test_app_qml_workspace.py | 4 + tests/test_packaging_metadata.py | 1 + 19 files changed, 773 insertions(+), 69 deletions(-) create mode 100644 src/carnopy/app/qml/Carnopy/components/WorkflowContextInspector.qml diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py index 2020d50..12c622e 100644 --- a/scripts/check_distribution.py +++ b/scripts/check_distribution.py @@ -66,6 +66,7 @@ "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", diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index 952e88f..0b7193f 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -354,8 +354,21 @@ 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_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() + ) hasAnyTransientEdit = Property( bool, @@ -465,6 +478,25 @@ def request_new_dataset(self, mode: str, discard_confirmed: bool = False) -> boo 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_active_plot_edit("New Model Sweep"): + return False + return self.configuration_controller.new_sweep(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_active_plot_edit("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"): @@ -676,7 +708,7 @@ def request_close_configuration(self, discard_confirmed: bool = False) -> bool: @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", "visualization"}: return False if not field.startswith(f"{section}.") and not ( section == "visualization" and field.startswith("plot.") @@ -1379,6 +1411,8 @@ def request_shutdown(self) -> bool: 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") description = " and ".join(edit_names) self.transientEditShutdownConfirmationRequested.emit( f"A {description} edit is still open. Cancel the edit and close Carnopy?" @@ -1432,6 +1466,11 @@ def confirm_transient_edit_shutdown(self, discard_confirmed: bool) -> bool: return False if self.get_has_session_plot_edit() and not self.session_plot_controller.cancel_edit(): return False + if ( + self.get_has_active_sweep_edit() + and not self.configuration_controller.sweep_draft.cancel_comparison() + ): + return False self.closeWindowRequested.emit() return True diff --git a/src/carnopy/app/qml/Carnopy/Main.qml b/src/carnopy/app/qml/Carnopy/Main.qml index 9353c51..69c8b6f 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,7 @@ ApplicationWindow { signal datasetSaveRequested(bool allowReformat) signal datasetValidateRequested signal datasetReloadRequested(bool discardConfirmed) + signal sweepNewRequested(bool discardConfirmed) signal runCancelRequested signal runForceStopRequested signal runGenerateRequested @@ -123,6 +124,9 @@ ApplicationWindow { ? desktopController.configurationController : null readonly property var executionController: controllerAvailable ? desktopController.executionController : null + readonly property var sweepWorkflowController: controllerAvailable + ? desktopController.sweepWorkflowController : + null readonly property var inspectionController: controllerAvailable ? desktopController.inspectionController : null readonly property var activityController: controllerAvailable @@ -160,6 +164,8 @@ ApplicationWindow { return qsTr("Help"); if (pageKey === "dataset") return qsTr("Dataset"); + if (pageKey === "sweeps") + return qsTr("Model Sweeps"); if (pageKey === "visualization") return qsTr("Visualization"); if (pageKey === "yaml") @@ -209,9 +215,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 +226,7 @@ ApplicationWindow { configurationDiscardDialog.open(); return; } - root.datasetImportRequested(path, false); + root.configurationImportRequested(path, false); } function requestDatasetNew(mode) { @@ -237,6 +243,54 @@ 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 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 requestCommandImport() { routeTo("workspace"); Qt.callLater(function () { @@ -440,6 +494,7 @@ 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 visualizationAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable yamlAvailable: root.configController !== null && root.configController.hasDocument @@ -496,6 +551,8 @@ ApplicationWindow { showAppearanceSelector: !root.inspectorWideVisible showRailMenu: root.shellMode === "narrow" statusLabel: { + if (root.currentPage === "sweeps") + return root.sweepStatusLabel(); if (root.currentPage === "run" && root.executionController !== null) return root.executionController.state === "running" ? qsTr("Running") : ( root.executionController.state @@ -516,33 +573,37 @@ 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" : ( - 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"))) + statusTone: root.currentPage === "sweeps" ? root.sweepStatusTone() : ( + 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" : + (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 +704,16 @@ 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: yamlPageLoader @@ -786,6 +857,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 +877,8 @@ ApplicationWindow { onInspectionExploreRequested: root.inspectionExploreRequested() onValidateRequested: root.datasetValidateRequested() pageKey: root.currentPage + 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 +918,7 @@ 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 visualizationAvailable: root.controllerAvailable && root.desktopController.workspaceAvailable yamlAvailable: root.configController !== null && root.configController.hasDocument @@ -869,6 +944,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 +962,8 @@ ApplicationWindow { onInspectionExploreRequested: root.inspectionExploreRequested() onValidateRequested: root.datasetValidateRequested() pageKey: root.currentPage + 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 +1000,9 @@ 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) + onNewSweepRequested: root.requestSweepNew() } } @@ -974,6 +1053,23 @@ 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: visualizationPage @@ -1122,9 +1218,9 @@ ApplicationWindow { } function onAttentionRequested(section, field, row) { - if (section !== "dataset" && section !== "visualization") + if (section !== "dataset" && section !== "sweep" && section !== "visualization") return; - root.routeTo(section); + root.routeTo(section === "sweep" ? "sweeps" : section); root.pendingAttentionField = field; root.pendingAttentionRow = row; root.pendingAttentionSerial += 1; @@ -1139,7 +1235,8 @@ ApplicationWindow { } function onConfigurationDocumentOpened(documentKind) { - root.routeTo(documentKind === "dataset" ? "dataset" : "yaml"); + root.routeTo(documentKind === "dataset" ? "dataset" : (documentKind === "model_sweep" + ? "sweeps" : "yaml")); } function onNavigationRequested(pageKey, detail) { @@ -1169,7 +1266,7 @@ 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") && !workspaceAvailable) root.routeTo("workspace"); if (root.currentPage === "yaml" && (root.configController === null || !root.configController.hasDocument)) @@ -1242,10 +1339,12 @@ ApplicationWindow { const mode = root.pendingReplacementMode; root.pendingReplacementMode = ""; root.datasetNewRequested(mode, true); + } else if (action === "new_sweep") { + root.sweepNewRequested(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 +1411,7 @@ ApplicationWindow { onAccepted: root.transientEditShutdownConfirmed(true) onRejected: root.transientEditShutdownConfirmed(false) rejectText: qsTr("Keep open") - title: qsTr("Unfinished plot edit") + title: qsTr("Unfinished edit") } DecisionDialog { @@ -1326,8 +1425,10 @@ 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?") : + qsTr("Cancel generation and close?")) } FileDialog { @@ -1338,7 +1439,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/ContextInspector.qml b/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml index 07d9acc..ae8b82e 100644 --- a/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml +++ b/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml @@ -15,11 +15,14 @@ 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 sweepDraft: null + property var sweepWorkflowController: null property string pageKey: "workspace" property bool visualizationActiveEdit: false property string visualizationIssue: "" @@ -100,7 +103,7 @@ Control { flickableDirection: Flickable.VerticalFlick pixelAligned: true visible: root.pageKey !== "run" && root.pageKey !== "inspect" && root.pageKey - !== "activity" + !== "activity" && root.pageKey !== "sweeps" ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded @@ -322,5 +325,25 @@ 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") + } } } diff --git a/src/carnopy/app/qml/Carnopy/components/NavRail.qml b/src/carnopy/app/qml/Carnopy/components/NavRail.qml index b466182..b835a31 100644 --- a/src/carnopy/app/qml/Carnopy/components/NavRail.qml +++ b/src/carnopy/app/qml/Carnopy/components/NavRail.qml @@ -15,6 +15,7 @@ Control { property bool inspectAvailable: false property bool activityAvailable: false property bool runAvailable: false + property bool sweepsAvailable: false property bool visualizationAvailable: false property bool yamlAvailable: false readonly property alias collapseControl: railCollapseButton @@ -103,8 +104,8 @@ 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" @@ -208,7 +209,9 @@ Control { && (pageKey !== "inspect" || root.inspectAvailable) && ( pageKey !== "activity" - || root.activityAvailable) + || root.activityAvailable) && ( + pageKey !== "sweeps" + || root.sweepsAvailable) Accessible.description: effectivelyAvailable ? "" : unavailableReason Accessible.name: title 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/pages/ModelSweepPage.qml b/src/carnopy/app/qml/Carnopy/pages/ModelSweepPage.qml index a54215c..197095d 100644 --- a/src/carnopy/app/qml/Carnopy/pages/ModelSweepPage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/ModelSweepPage.qml @@ -27,9 +27,11 @@ Item { property string samplerAttentionField: "" property int samplerAttentionSerial: 0 readonly property var datasetDraft: sweepDraft.datasetDraft - readonly property bool locked: !configController.canEdit + 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) @@ -143,14 +145,33 @@ Item { } StatusBadge { - label: root.sweepDraft.hasActiveComparisonEdit ? qsTr("Comparison edit open") : ( - root.sweepDraft.locallyValid - ? qsTr("Locally complete") : - qsTr("Needs attention")) + 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.sweepDraft.hasActiveComparisonEdit ? "warning" : ( - root.sweepDraft.locallyValid - ? "success" : "danger") + 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" } } @@ -161,7 +182,7 @@ Item { row: root.sweepDraft.firstInvalidRow section: "sweep" title: qsTr("Sweep configuration needs attention") - visible: !root.sweepDraft.locallyValid + visible: root.documentActive && !root.sweepDraft.locallyValid onActionRequested: (section, field, row) => root.focusField(field, row) } @@ -181,6 +202,7 @@ Item { minimumCardWidth: 300 objectName: "modelSweepDefinitionGrid" uniformHeights: false + visible: root.documentActive Card { id: modelCard @@ -449,6 +471,7 @@ Item { subtitle: qsTr( "Committed order is serialized deterministically and participates in plan identity.") title: qsTr("Comparison plots") + visible: root.documentActive RowLayout { Layout.fillWidth: true diff --git a/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml b/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml index e3e95ab..cede95c 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,9 @@ 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 newSweepRequested property bool importSelectionAccepted: false property string importSelectionPath: "" @@ -90,7 +93,7 @@ Item { if (window !== null) window.requestActivate(); if (root.controllerAvailable) - root.importDatasetRequested(path); + root.importConfigurationRequested(path); }); } @@ -323,7 +326,7 @@ Item { maximumColumns: 3 minimumCardWidth: 300 objectName: "newDatasetModeGrid" - visible: root.workspaceState === "landing" + visible: root.configurationActionsVisible Repeater { model: root.controllerAvailable @@ -357,10 +360,31 @@ 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 + 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 diff --git a/src/carnopy/app/qml/Carnopy/qmldir b/src/carnopy/app/qml/Carnopy/qmldir index 785e9f5..6bfb0d3 100644 --- a/src/carnopy/app/qml/Carnopy/qmldir +++ b/src/carnopy/app/qml/Carnopy/qmldir @@ -29,6 +29,7 @@ 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 diff --git a/src/carnopy/app/qml_resources.py b/src/carnopy/app/qml_resources.py index 5a0edf0..012584c 100644 --- a/src/carnopy/app/qml_resources.py +++ b/src/carnopy/app/qml_resources.py @@ -50,6 +50,7 @@ "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", diff --git a/src/carnopy/app/qml_runtime.py b/src/carnopy/app/qml_runtime.py index 43dfdc5..5216620 100644 --- a/src/carnopy/app/qml_runtime.py +++ b/src/carnopy/app/qml_runtime.py @@ -488,7 +488,11 @@ 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), + ( + "configurationImportRequested", + self.controller.request_import_configuration, + ), ("datasetSaveRequested", self.controller.request_save), ("datasetSaveAsRequested", self.controller.request_save_as), ( diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index fbb1bdc..0eb4f2b 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -396,6 +396,37 @@ def test_qml_shutdown_explicitly_cancels_transient_plot_edits_before_close( 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] = [] + desktop.transientEditShutdownConfirmationRequested.connect(confirmations.append) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + monkeypatch.setattr(desktop, "get_has_active_sweep_edit", lambda: True) + monkeypatch.setattr( + desktop.configuration_controller.sweep_draft, + "cancel_comparison", + lambda: cancellations.append("comparison") or True, + ) + + 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 desktop.confirm_transient_edit_shutdown(True) + assert cancellations == ["comparison"] + assert close_requests == ["close"] + + def test_configuration_attention_facade_accepts_only_stable_sections( tmp_path: Path, application: QCoreApplication, @@ -408,11 +439,13 @@ def test_configuration_attention_facade_accepts_only_stable_sections( ) 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("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), ("visualization", "plot.name", -1), ] assert desktop.shutdown() @@ -494,6 +527,35 @@ def test_sweep_workflow_facade_routes_only_the_integrated_workflow( assert desktop.shutdown() +def test_sweep_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, + "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_import_configuration(QUrl.fromLocalFile(str(source)).toString(), True) + assert calls == [ + ("new_sweep", True), + ("open", str(source), True), + ] + assert desktop.shutdown() + + def test_sweep_editor_facade_enforces_document_and_worker_edit_guards( tmp_path: Path, application: QCoreApplication, @@ -850,7 +912,9 @@ def test_active_plot_edit_blocks_all_composition_lifecycle_paths( ) for name in ( "new_dataset", + "new_sweep", "import_dataset", + "import_configuration", "request_save", "request_save_as", "request_validation", @@ -865,7 +929,9 @@ def test_active_plot_edit_blocks_all_composition_lifecycle_paths( ) assert not desktop.request_new_dataset("property_table") + assert not desktop.request_new_sweep() 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() diff --git a/tests/test_app_qml_config.py b/tests/test_app_qml_config.py index 2e472d7..89797d9 100644 --- a/tests/test_app_qml_config.py +++ b/tests/test_app_qml_config.py @@ -48,7 +48,7 @@ def _process_events() -> None: application.processEvents() -def test_global_shell_routes_non_dataset_documents_to_yaml_preview( +def test_global_shell_routes_sweep_documents_to_the_structured_editor( runtime: QmlApplicationRuntime, ) -> None: root = runtime.engine.rootObjects()[0] @@ -58,7 +58,8 @@ def test_global_shell_routes_non_dataset_documents_to_yaml_preview( _process_events() assert runtime.controller.configuration_controller.get_document_kind() == "model_sweep" - assert root.property("currentPage") == "yaml" + assert root.property("currentPage") == "sweeps" + assert root.findChild(QObject, "modelSweepPage") is not None def _wait_for_idle(runtime: QmlApplicationRuntime) -> None: diff --git a/tests/test_app_qml_dataset.py b/tests/test_app_qml_dataset.py index 0254010..5264c2d 100644 --- a/tests/test_app_qml_dataset.py +++ b/tests/test_app_qml_dataset.py @@ -603,6 +603,7 @@ 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 "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 @@ -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_runtime.py b/tests/test_app_qml_runtime.py index dc6ae6e..fb89504 100644 --- a/tests/test_app_qml_runtime.py +++ b/tests/test_app_qml_runtime.py @@ -642,4 +642,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 42 file(s).\n" + assert completed.stdout == "QML checks passed for 43 file(s).\n" diff --git a/tests/test_app_qml_shell.py b/tests/test_app_qml_shell.py index ab39374..7817bb0 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, False, False) nav_source = (ROOT / "src/carnopy/app/qml/Carnopy/components/NavRail.qml").read_text( encoding="utf-8" ) @@ -165,6 +165,8 @@ 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 root.property("hasFake3dViewport") is False diff --git a/tests/test_app_qml_sweep.py b/tests/test_app_qml_sweep.py index c5b46e7..ee92520 100644 --- a/tests/test_app_qml_sweep.py +++ b/tests/test_app_qml_sweep.py @@ -9,7 +9,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") pytest.importorskip("PySide6") -from PySide6.QtCore import QCoreApplication, QEventLoop, QSettings, QTimer +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 @@ -17,6 +17,7 @@ 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] @@ -126,6 +127,137 @@ def _visual_names(root: QQuickItem) -> set[str]: 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, @@ -231,14 +363,20 @@ 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 @@ -252,3 +390,7 @@ def test_sweep_qml_resources_and_controller_boundary_are_explicit() -> None: 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_workspace.py b/tests/test_app_qml_workspace.py index 13d447b..3be0528 100644 --- a/tests/test_app_qml_workspace.py +++ b/tests/test_app_qml_workspace.py @@ -230,6 +230,10 @@ 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 runtime.warning_capture.runtime_warnings == () diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 8582efc..3bdeb5f 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -80,6 +80,7 @@ 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", From 840987475ecd576d481726d7b00a30f565e6f18d Mon Sep 17 00:00:00 2001 From: gca Date: Tue, 11 Aug 2026 03:53:36 +0200 Subject: [PATCH 14/45] feat(app): derive preparation source profiles --- src/carnopy/app/source_inspection.py | 224 +++++++++++++++++++++++++-- src/carnopy/preparation/fields.py | 77 +++++++++ src/carnopy/preparation/reference.py | 59 +++++-- tests/test_app_inspection.py | 219 ++++++++++++++++++++++++++ tests/test_app_worker.py | 24 ++- 5 files changed, 575 insertions(+), 28 deletions(-) diff --git a/src/carnopy/app/source_inspection.py b/src/carnopy/app/source_inspection.py index 7dccf52..29f43a2 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,7 @@ 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 def public_payload(self) -> dict[str, Any]: return { @@ -78,6 +90,7 @@ 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, } @@ -108,10 +121,13 @@ 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, + eligible, ineligible_reason, preparation_descriptor, preparation_profile = ( + _preparation_eligibility( + requested, + kind, + catalog, + summary, + ) ) return ResolvedInspection( source=requested, @@ -124,6 +140,7 @@ 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, ) @@ -544,25 +561,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/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/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/tests/test_app_inspection.py b/tests/test_app_inspection.py index 5b2b7f4..c5c844c 100644 --- a/tests/test_app_inspection.py +++ b/tests/test_app_inspection.py @@ -16,6 +16,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 +126,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 +134,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() diff --git a/tests/test_app_worker.py b/tests/test_app_worker.py index d0bda77..b484101 100644 --- a/tests/test_app_worker.py +++ b/tests/test_app_worker.py @@ -695,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( @@ -705,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], @@ -712,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 @@ -729,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", @@ -750,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, From 50af7efd4e42b4b769caf0685261dcb3907eec32 Mon Sep 17 00:00:00 2001 From: gca Date: Tue, 11 Aug 2026 04:43:33 +0200 Subject: [PATCH 15/45] feat(app): project preparation source profiles --- src/carnopy/app/inspection_controller.py | 639 ++++++++++++++++++++++- tests/test_app_inspection_controller.py | 295 ++++++++++- tests/test_app_workflow_controller.py | 48 ++ 3 files changed, 978 insertions(+), 4 deletions(-) diff --git a/src/carnopy/app/inspection_controller.py b/src/carnopy/app/inspection_controller.py index b51860f..2b19a77 100644 --- a/src/carnopy/app/inspection_controller.py +++ b/src/carnopy/app/inspection_controller.py @@ -56,6 +56,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 = "" @@ -69,6 +70,7 @@ def __init__( 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 +98,64 @@ 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) + 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 +214,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 +361,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 +529,74 @@ def get_preparation_quality_errors_model(self) -> QObject: 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 +672,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() @@ -541,6 +812,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 +824,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 +836,44 @@ 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 self._payload = copy.deepcopy(payload) self._source_kind = source_kind self._revision = revision @@ -590,6 +884,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) @@ -599,6 +894,8 @@ def _accept_inspection_payload(self, payload: dict[str, Any]) -> None: self._state = "ready" self._preview_state = "empty" self._project_payload(payload) + 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) @@ -640,6 +937,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) @@ -672,6 +971,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 +981,7 @@ def _clear_inspection( self._plot_context = None self._session = None self._request_kind = "" + self._requested_inspection_source = None self.table_model.clear() self._reset_projection() if had_payload: @@ -699,6 +1000,14 @@ 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, @@ -893,6 +1202,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 +1350,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/tests/test_app_inspection_controller.py b/tests/test_app_inspection_controller.py index 63fd38c..e973262 100644 --- a/tests/test_app_inspection_controller.py +++ b/tests/test_app_inspection_controller.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import json import os import subprocess import sys @@ -49,6 +51,97 @@ 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 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 +268,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 +279,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 +295,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() @@ -334,7 +598,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 +622,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 +661,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_workflow_controller.py b/tests/test_app_workflow_controller.py index 65079ee..b003577 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -264,6 +264,53 @@ def _accept_preparation_inspection( "controls": {}, "tables": [], } + 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": [], + "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": [ + { + "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( { @@ -277,6 +324,7 @@ def _accept_preparation_inspection( "preparation_eligible": True, "preparation_ineligible_reason": "", "preparation_source_descriptor": descriptor, + "preparation_profile": profile, } ) return descriptor From 4ce7ee665e360a49b5093b6bf86e1b10e2b76f45 Mon Sep 17 00:00:00 2001 From: gca Date: Tue, 11 Aug 2026 05:12:39 +0200 Subject: [PATCH 16/45] feat(app): add explicit preparation source binding --- src/carnopy/app/workflow_controller.py | 241 ++++++++++++++++++++++--- tests/test_app_workflow_controller.py | 132 +++++++++++++- 2 files changed, 347 insertions(+), 26 deletions(-) diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index d0b945a..7dafa3d 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -2,6 +2,8 @@ import copy import hashlib +import json +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast @@ -34,6 +36,42 @@ ResultRelation = Literal["unavailable", "current", "stale", "unrelated"] +@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): """Private nonvisual load/validate/plan/execute state for one workflow.""" @@ -984,47 +1022,197 @@ def __init__( ) -> None: super().__init__(coordinator, kind="preparation", parent=parent) self.inspection = inspection + self._source_binding: _PreparationSourceBinding | None = None + self._source_binding_issue = "" inspection.inspection_changed.connect(self._inspection_changed) self._refresh_typed_projections() - def _plan_context(self) -> dict[str, object]: - snapshot = self.inspection.preparation_source_snapshot() - if snapshot is None: + def get_has_bound_source(self) -> bool: + return self._source_binding is not None + + hasBoundSource = Property(bool, get_has_bound_source, notify=WorkflowController.state_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=WorkflowController.state_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=WorkflowController.state_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=WorkflowController.state_changed, + ) + + def get_source_binding_issue(self) -> str: + return self._source_binding_issue + + sourceBindingIssue = Property( + str, + get_source_binding_issue, + notify=WorkflowController.state_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=WorkflowController.state_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=WorkflowController.state_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=WorkflowController.state_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.state_changed.emit() + return True + + 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.state_changed.emit() + return True + + 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: + if workspace != self.workspace: + self._source_binding = None + self._source_binding_issue = "" + super().set_workspace(workspace) + + 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 _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 _inspection_changed(self, _payload: object) -> None: 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.state_changed.emit() + def _text(value: object) -> str: return value if isinstance(value, str) else "" @@ -1032,3 +1220,14 @@ def _text(value: object) -> str: 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/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index b003577..664db85 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -1030,7 +1030,7 @@ def coordinator_for_job_records(workspace: Workspace) -> list[dict[str, object]] return [cast(dict[str, object], item.data) for item in records if item.data is not None] -def test_preparation_controller_rejects_a_plan_for_replaced_inspection( +def test_preparation_binding_does_not_follow_inspection_and_cannot_change_during_plan( tmp_path: Path, application: QCoreApplication, ) -> None: @@ -1054,6 +1054,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 @@ -1062,6 +1067,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, @@ -1072,9 +1084,11 @@ 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, @@ -1107,6 +1121,7 @@ def test_preparation_plan_currentness_uses_the_complete_inspection_context( source, revision=revision, ) + assert controller.bind_inspected_source() assert controller.plan() _finish_plan( transport, @@ -1121,6 +1136,9 @@ def test_preparation_plan_currentness_uses_the_complete_inspection_context( 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, @@ -1128,6 +1146,11 @@ def test_preparation_plan_currentness_uses_the_complete_inspection_context( 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 @@ -1140,12 +1163,93 @@ def test_preparation_plan_currentness_uses_the_complete_inspection_context( 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_keeps_the_source_context_used_by_execution( tmp_path: Path, application: QCoreApplication, @@ -1170,6 +1274,7 @@ def test_preparation_result_keeps_the_source_context_used_by_execution( source, revision=revision, ) + assert controller.bind_inspected_source() assert controller.plan() _finish_plan( transport, @@ -1189,11 +1294,27 @@ def test_preparation_result_keeps_the_source_context_used_by_execution( 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" + record = next( + item + for item in coordinator_for_job_records(workspace) + if item["operation"] == "execute_preparation" + ) + 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 controller.bind_inspected_source() assert controller.get_result_relation() == "stale" restored_descriptor = _accept_preparation_inspection( @@ -1202,6 +1323,7 @@ def test_preparation_result_keeps_the_source_context_used_by_execution( revision=revision, ) assert restored_descriptor == descriptor + assert controller.bind_inspected_source() assert controller.get_result_relation() == "current" coordinator.shutdown() @@ -1327,5 +1449,5 @@ def test_workflow_failure_and_preparation_source_blockers_are_typed( assert controller.get_workflow_state() == "failed" assert controller.get_failure_category() == "request" assert controller.get_failure_code() == "plan_unavailable" - assert "eligible preparation source" in controller.get_failure_message() + assert "use an inspected source" in controller.get_failure_message() coordinator.shutdown() From 115db0d35bea0bd190d07282306c6aebf6726004 Mon Sep 17 00:00:00 2001 From: gca Date: Tue, 11 Aug 2026 05:36:27 +0200 Subject: [PATCH 17/45] feat(app): expose preparation source binding actions --- src/carnopy/app/desktop_controller.py | 22 ++++ src/carnopy/app/qml/Carnopy/Main.qml | 24 ++++ .../app/qml/Carnopy/pages/InspectPage.qml | 92 ++++++++++++++ src/carnopy/app/qml_runtime.py | 8 ++ src/carnopy/app/workflow_controller.py | 27 +++-- tests/test_app_desktop_controller.py | 44 +++++++ tests/test_app_qml_inspection.py | 114 ++++++++++++++++++ 7 files changed, 322 insertions(+), 9 deletions(-) diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index 0b7193f..fa5cb4c 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -46,6 +46,7 @@ class DesktopController(QObject): navigationRequested = Signal(str, str) activityActionFailed = Signal(str, str) busyShutdownConfirmationRequested = Signal(str, str) + preparationSourceClearConfirmationRequested = Signal() def __init__( self, @@ -403,6 +404,15 @@ def get_sweep_workflow_controller(self) -> QObject: 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 @@ -580,6 +590,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() diff --git a/src/carnopy/app/qml/Carnopy/Main.qml b/src/carnopy/app/qml/Carnopy/Main.qml index 69c8b6f..9db29bd 100644 --- a/src/carnopy/app/qml/Carnopy/Main.qml +++ b/src/carnopy/app/qml/Carnopy/Main.qml @@ -60,6 +60,8 @@ ApplicationWindow { signal inspectionRefreshRequested signal inspectionSourcesRefreshRequested signal inspectionTableRequested(string tableId) + signal preparationSourceBindRequested + signal preparationSourceClearRequested(bool confirmed) signal activityInspectRunRequested signal activityRecordSelectionRequested(string recordId) signal activityRecordsRefreshRequested @@ -127,6 +129,9 @@ ApplicationWindow { 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 @@ -1153,8 +1158,11 @@ ApplicationWindow { InspectPage { inspectionController: root.inspectionController objectName: "inspectPage" + preparationWorkflowController: root.preparationWorkflowController onInspectSourceRequested: path => root.inspectionInspectRequested(path) onMoreSourcesRequested: root.inspectionMoreSourcesRequested() + onPreparationSourceBindRequested: root.preparationSourceBindRequested() + onPreparationSourceClearRequested: root.preparationSourceClearRequested(false) onPreviewPageRequested: pageOffset => root.inspectionPreviewPageRequested(pageOffset) onRefreshRequested: root.inspectionRefreshRequested() onRefreshSourcesRequested: root.inspectionSourcesRefreshRequested() @@ -1253,6 +1261,10 @@ ApplicationWindow { }); } + function onPreparationSourceClearConfirmationRequested() { + preparationSourceClearDialog.open(); + } + function onShutdownConfirmationRequested() { shutdownDiscardDialog.open(); } @@ -1414,6 +1426,18 @@ ApplicationWindow { 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 { id: busyShutdownDialog diff --git a/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml b/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml index 0fa5406..94e041f 100644 --- a/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml @@ -11,6 +11,7 @@ Item { id: root required property var inspectionController + required property var preparationWorkflowController property bool focusTable: false property int selectedTab: 0 property bool fileSelectionAccepted: false @@ -20,6 +21,8 @@ Item { signal inspectSourceRequested(string path) signal moreSourcesRequested + signal preparationSourceBindRequested + signal preparationSourceClearRequested signal previewPageRequested(int pageOffset) signal refreshRequested signal refreshSourcesRequested @@ -484,6 +487,95 @@ 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") + + RowLayout { + 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") + } + + Item { + Layout.fillWidth: true + } + + AppButton { + compact: true + 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")) + visible: root.inspectionController.preparationEligible + + 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 { + compact: true + enabled: root.preparationWorkflowController.hasBoundSource + && root.inspectionController.canInspect + objectName: "preparationClearSourceButton" + onClicked: root.preparationSourceClearRequested() + text: qsTr("Clear source") + tone: "quiet" + 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 diff --git a/src/carnopy/app/qml_runtime.py b/src/carnopy/app/qml_runtime.py index 5216620..01b8027 100644 --- a/src/carnopy/app/qml_runtime.py +++ b/src/carnopy/app/qml_runtime.py @@ -556,6 +556,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, diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index 7dafa3d..ab577eb 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -1014,6 +1014,8 @@ def __init__( class PreparationWorkflowController(WorkflowController): + source_binding_changed = Signal() + def __init__( self, coordinator: DesktopRequestCoordinator, @@ -1030,13 +1032,13 @@ def __init__( def get_has_bound_source(self) -> bool: return self._source_binding is not None - hasBoundSource = Property(bool, get_has_bound_source, notify=WorkflowController.state_changed) + 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=WorkflowController.state_changed) + boundSourcePath = Property(str, get_bound_source_path, notify=source_binding_changed) def get_bound_source_kind(self) -> str: binding = self._source_binding @@ -1045,7 +1047,7 @@ def get_bound_source_kind(self) -> str: value = binding.profile().get("source_kind") return value if isinstance(value, str) else "" - boundSourceKind = Property(str, get_bound_source_kind, notify=WorkflowController.state_changed) + boundSourceKind = Property(str, get_bound_source_kind, notify=source_binding_changed) def get_bound_source_revision(self) -> str: binding = self._source_binding @@ -1054,7 +1056,7 @@ def get_bound_source_revision(self) -> str: boundSourceRevision = Property( str, get_bound_source_revision, - notify=WorkflowController.state_changed, + notify=source_binding_changed, ) def get_source_binding_issue(self) -> str: @@ -1063,7 +1065,7 @@ def get_source_binding_issue(self) -> str: sourceBindingIssue = Property( str, get_source_binding_issue, - notify=WorkflowController.state_changed, + notify=source_binding_changed, ) def get_inspected_source_matches_binding(self) -> bool: @@ -1073,7 +1075,7 @@ def get_inspected_source_matches_binding(self) -> bool: inspectedSourceMatchesBinding = Property( bool, get_inspected_source_matches_binding, - notify=WorkflowController.state_changed, + notify=source_binding_changed, ) def get_inspected_source_available(self) -> bool: @@ -1083,7 +1085,7 @@ def get_inspected_source_available(self) -> bool: inspectedSourceAvailable = Property( bool, get_inspected_source_available, - notify=WorkflowController.state_changed, + notify=source_binding_changed, ) def get_bound_source_refresh_available(self) -> bool: @@ -1099,7 +1101,7 @@ def get_bound_source_refresh_available(self) -> bool: boundSourceRefreshAvailable = Property( bool, get_bound_source_refresh_available, - notify=WorkflowController.state_changed, + notify=source_binding_changed, ) def bind_inspected_source(self) -> bool: @@ -1120,6 +1122,7 @@ def bind_inspected_source(self) -> bool: return False self._source_binding = candidate self._set_source_binding_issue("") + self.source_binding_changed.emit() self.state_changed.emit() return True @@ -1134,6 +1137,7 @@ def clear_bound_source(self) -> bool: return False self._source_binding = None self._set_source_binding_issue("") + self.source_binding_changed.emit() self.state_changed.emit() return True @@ -1151,10 +1155,13 @@ def bound_source_snapshot( ) def set_workspace(self, workspace: Workspace | None) -> None: - if workspace != self.workspace: + 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 @@ -1188,6 +1195,7 @@ def _activity_source_identity(self) -> dict[str, Any] | None: } def _inspection_changed(self, _payload: object) -> None: + self.source_binding_changed.emit() self.state_changed.emit() def _inspected_source_binding(self) -> _PreparationSourceBinding | None: @@ -1211,6 +1219,7 @@ 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() diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 0eb4f2b..5e9e32c 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -132,6 +132,9 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity 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") @@ -633,6 +636,47 @@ def test_sweep_result_handoff_inspects_the_exact_finalized_output( assert desktop.shutdown() +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")) + 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_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_record_changes_refresh_the_shared_activity_projection( tmp_path: Path, application: QCoreApplication, diff --git a/tests/test_app_qml_inspection.py b/tests/test_app_qml_inspection.py index 2873930..b816a42 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,74 @@ 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, + } + ) + + @pytest.fixture def runtime( tmp_path: Path, @@ -192,3 +261,48 @@ 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 == () From 944e1a8644ec7d5ee7dc36115fcee74b1eb71a90 Mon Sep 17 00:00:00 2001 From: gca Date: Tue, 11 Aug 2026 06:12:45 +0200 Subject: [PATCH 18/45] feat(app): add preparation role drafts --- src/carnopy/app/config_controller.py | 40 +- src/carnopy/app/desktop_controller.py | 8 + src/carnopy/app/field_ids.py | 8 + src/carnopy/app/preparation_draft.py | 614 ++++++++++++++++++++++++++ tests/test_app_config_controller.py | 56 +++ tests/test_app_desktop_controller.py | 42 ++ tests/test_app_preparation_draft.py | 248 +++++++++++ 7 files changed, 1014 insertions(+), 2 deletions(-) create mode 100644 src/carnopy/app/preparation_draft.py create mode 100644 tests/test_app_preparation_draft.py diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index 917d6b8..0529a9b 100644 --- a/src/carnopy/app/config_controller.py +++ b/src/carnopy/app/config_controller.py @@ -22,6 +22,7 @@ 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, @@ -62,6 +63,7 @@ def __init__( parent: QObject | None = None, *, sweep_draft: SweepDraft | None = None, + preparation_draft: PreparationDraft | None = None, ) -> None: super().__init__(parent) self._owns_coordinator = coordinator is None @@ -72,6 +74,7 @@ def __init__( 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: ConfigurationDocument | None = None self.capabilities: dict[str, Any] | None = None @@ -98,12 +101,15 @@ def __init__( 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.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: @@ -144,6 +150,8 @@ def get_dirty(self) -> bool: 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 ( @@ -209,6 +217,8 @@ def get_blocking_section(self) -> str: 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: + return "preparation" if self._locally_valid: return "none" if not self.dataset_draft.get_locally_valid(): @@ -223,6 +233,8 @@ 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": @@ -235,6 +247,8 @@ 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": @@ -247,6 +261,8 @@ def get_blocking_issue(self) -> str: section = self.get_blocking_section() if section == "sweep": return self.sweep_draft.get_issue() + if section == "preparation": + return self.preparation_draft.get_issue() if section == "dataset": return self.dataset_draft.get_issue() if section == "visualization": @@ -364,6 +380,11 @@ def get_sweep_draft(self) -> QObject: 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 @@ -586,14 +607,17 @@ def open_document(self, document: ConfigurationDocument) -> bool: 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("_", " ") @@ -633,8 +657,9 @@ def execution_snapshot( drafts_valid = ( self.sweep_draft.get_locally_valid() if expected_document_type == "model_sweep" - else expected_document_type != "dataset" - or ( + 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() ) @@ -806,6 +831,10 @@ 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) @@ -824,6 +853,7 @@ def _clear_document(self) -> None: 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 @@ -904,6 +934,8 @@ def _finish_save(self, *, replace: bool, validation_current: bool) -> None: document.mark_saved(destination, content) 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() @@ -941,6 +973,10 @@ def _refresh_document(self, *, validation_revision_changed: bool = True) -> None 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() diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index fa5cb4c..1687958 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -86,6 +86,9 @@ def __init__( self.inspection_controller, self, ) + self.preparation_workflow_controller.source_binding_changed.connect( + self._preparation_source_binding_changed + ) self.activity_controller = ActivityController( self.request_coordinator, self, @@ -1537,6 +1540,11 @@ def _configuration_state_changed(self) -> None: 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 and self._pending_busy_shutdown: diff --git a/src/carnopy/app/field_ids.py b/src/carnopy/app/field_ids.py index e9bae36..a1e43e1 100644 --- a/src/carnopy/app/field_ids.py +++ b/src/carnopy/app/field_ids.py @@ -20,6 +20,14 @@ 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" + 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/preparation_draft.py b/src/carnopy/app/preparation_draft.py new file mode 100644 index 0000000..5cd976f --- /dev/null +++ b/src/carnopy/app/preparation_draft.py @@ -0,0 +1,614 @@ +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.draft_models import DraftItem, DraftListModel +from carnopy.app.field_ids import ( + PREPARATION_AUXILIARY, + PREPARATION_CATEGORICAL_FEATURES, + PREPARATION_FEATURES, + PREPARATION_SOURCE_POLICY, + PREPARATION_TARGETS, +) + +DERIVED_FEATURES = ( + "specific_volume", + "reduced_temperature", + "reduced_pressure", + "compressibility_factor", +) +CATEGORICAL_FIELDS = ("phase", "fluid") + + +class PreparationDraft(QObject): + """Compose source-independent Preparation roles for the global document.""" + + changed = Signal() + validity_changed = Signal() + dirty_changed = Signal() + profile_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._profile: 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._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_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_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) + + "." + ) + 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_first_invalid_field(self) -> str: + 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 + return PREPARATION_FEATURES + + firstInvalidField = Property(str, get_first_invalid_field, notify=validity_changed) + + def get_first_invalid_row(self) -> int: + 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_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") + self._loading = True + try: + 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 + 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.changed.emit() + + def clear(self) -> None: + self._loading = True + try: + 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._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.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() + + def payload(self) -> 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) + 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 raw_state(self) -> tuple[object, ...]: + return ( + self._loaded, + self._allow_partial_sweep, + self._numeric, + self._derived, + tuple(self._categorical.items()), + self._targets, + self._auxiliary, + ) + + 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)) + + 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.changed.emit() + + 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 + ) + + 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 _display(value: str) -> str: + return value.replace("_", " ").title() diff --git a/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index 80f7161..1b5b7f6 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -215,6 +215,12 @@ def sweep_payload() -> dict[str, Any]: 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[ConfigurationController, StubCoordinator]: @@ -812,3 +818,53 @@ def test_sweep_draft_composes_the_global_saved_document_and_validation_snapshot( assert sweep.cancel_comparison() assert controller.execution_snapshot(expected_document_type="model_sweep") == snapshot + + +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 diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 5e9e32c..5a15288 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -96,6 +96,10 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity 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.configuration_controller @@ -128,6 +132,10 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity 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 @@ -677,6 +685,40 @@ def test_preparation_source_facade_requires_confirmation_for_a_current_plan( assert desktop.shutdown() +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")) + source = tmp_path / "workspace" / "outputs" / "dataset-run" + profile = {"source_kind": "dataset_run", "inspection_revision": "a" * 64} + applied: list[object] = [] + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "bound_source_snapshot", + lambda: (source, "a" * 64, {"source_path": str(source)}, profile), + ) + monkeypatch.setattr( + desktop.configuration_controller.preparation_draft, + "apply_source_profile", + applied.append, + ) + + desktop._preparation_source_binding_changed() + assert applied == [profile] + + monkeypatch.setattr( + desktop.preparation_workflow_controller, + "bound_source_snapshot", + lambda: None, + ) + desktop._preparation_source_binding_changed() + assert applied == [profile, None] + assert desktop.shutdown() + + def test_execution_record_changes_refresh_the_shared_activity_projection( tmp_path: Path, application: QCoreApplication, diff --git a/tests/test_app_preparation_draft.py b/tests/test_app_preparation_draft.py new file mode 100644 index 0000000..5911158 --- /dev/null +++ b/tests/test_app_preparation_draft.py @@ -0,0 +1,248 @@ +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}, + "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, + "reference_context": {"compatible": True, "reason": ""}, + } + + +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" From e21c89bc2fbafa0cb351185ddb28f25e6c9d786f Mon Sep 17 00:00:00 2001 From: gca Date: Wed, 12 Aug 2026 04:02:31 +0200 Subject: [PATCH 19/45] feat(app): add preparation output and quality drafts --- src/carnopy/app/config_controller.py | 1 + src/carnopy/app/field_ids.py | 3 + src/carnopy/app/preparation_draft.py | 513 +++++++++++++++++++++++++++ tests/test_app_config_controller.py | 37 ++ tests/test_app_preparation_draft.py | 174 +++++++++ 5 files changed, 728 insertions(+) diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index 0529a9b..2bd323f 100644 --- a/src/carnopy/app/config_controller.py +++ b/src/carnopy/app/config_controller.py @@ -840,6 +840,7 @@ def _apply_capabilities(self, payload: dict[str, Any]) -> None: 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: diff --git a/src/carnopy/app/field_ids.py b/src/carnopy/app/field_ids.py index a1e43e1..25b1803 100644 --- a/src/carnopy/app/field_ids.py +++ b/src/carnopy/app/field_ids.py @@ -27,6 +27,9 @@ 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" def dataset_grid_field(axis: str, field: str) -> str: diff --git a/src/carnopy/app/preparation_draft.py b/src/carnopy/app/preparation_draft.py index 5cd976f..179f7c0 100644 --- a/src/carnopy/app/preparation_draft.py +++ b/src/carnopy/app/preparation_draft.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import math from collections.abc import Mapping from typing import Any @@ -10,8 +11,11 @@ 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_SOURCE_POLICY, PREPARATION_TARGETS, ) @@ -23,6 +27,9 @@ "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): @@ -32,6 +39,7 @@ class PreparationDraft(QObject): validity_changed = Signal() dirty_changed = Signal() profile_changed = Signal() + capability_changed = Signal() message = Signal(str) def __init__(self, parent: QObject | None = None) -> None: @@ -41,7 +49,10 @@ def __init__(self, parent: QObject | None = None) -> None: 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._profile: dict[str, Any] = {} + self._capabilities: dict[str, Any] = {} self._preserved: dict[str, Any] | None = None self._numeric: tuple[str, ...] = () self._derived: tuple[str, ...] = () @@ -51,6 +62,25 @@ def __init__(self, parent: QObject | None = None) -> None: 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._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 @@ -82,6 +112,16 @@ def get_categorical_choices(self) -> QObject: 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_allow_partial_sweep(self) -> bool: return self._allow_partial_sweep @@ -104,6 +144,216 @@ def _set_allow_partial_sweep_property(self, value: bool) -> None: 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_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 "" @@ -201,6 +451,12 @@ def get_first_invalid_field(self) -> str: 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) @@ -220,6 +476,48 @@ def get_dirty(self) -> bool: 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: @@ -237,6 +535,8 @@ def load_payload(self, payload: Mapping[str, object]) -> None: 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")) self._loading = True try: self._preserved = copy.deepcopy(value) @@ -260,6 +560,37 @@ def load_payload(self, payload: Mapping[str, object]) -> None: 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._loaded = True self._refresh_models() finally: @@ -268,6 +599,7 @@ def load_payload(self, payload: Mapping[str, object]) -> None: self._baseline_raw = self.raw_state() self.validity_changed.emit() self.dirty_changed.emit() + self.capability_changed.emit() self.changed.emit() def clear(self) -> None: @@ -282,6 +614,17 @@ def clear(self) -> None: 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._baseline = None self._baseline_raw = None self._loaded = False @@ -290,6 +633,7 @@ def clear(self) -> None: self._loading = False self.validity_changed.emit() self.dirty_changed.emit() + self.capability_changed.emit() self.changed.emit() def mark_baseline(self) -> None: @@ -320,6 +664,38 @@ def payload(self) -> dict[str, Any]: ] result["targets"] = list(self._targets) result["auxiliary"] = list(self._auxiliary) + 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: @@ -335,6 +711,17 @@ def raw_state(self) -> tuple[object, ...]: tuple(self._categorical.items()), self._targets, self._auxiliary, + 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, ...]: @@ -448,6 +835,68 @@ def observed_categories(self, field: str) -> list[str]: 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 @@ -455,6 +904,7 @@ def _state_changed(self) -> None: self.validity_changed.emit() self.dirty_changed.emit() self.profile_changed.emit() + self.capability_changed.emit() self.changed.emit() def _role_model(self, role: str) -> DraftListModel | None: @@ -514,6 +964,32 @@ def _refresh_models(self) -> None: ) 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 + ) def _role_items(self, role: str) -> tuple[DraftItem, ...]: key = { @@ -610,5 +1086,42 @@ 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() diff --git a/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index 1b5b7f6..8be4f70 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -868,3 +868,40 @@ def test_preparation_role_draft_composes_and_restores_the_exact_saved_document( 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() diff --git a/tests/test_app_preparation_draft.py b/tests/test_app_preparation_draft.py index 5911158..de66df1 100644 --- a/tests/test_app_preparation_draft.py +++ b/tests/test_app_preparation_draft.py @@ -84,6 +84,27 @@ def _profile(*, complete: bool = True) -> dict[str, object]: } +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"}] @@ -246,3 +267,156 @@ def test_clear_removes_document_state_but_retains_source_projection() -> None: assert not draft.get_dirty() assert draft.get_profile_available() assert draft.get_source_kind() == "dataset_run" + + +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.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_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 From 498fc6120732772a74aa9a07504c581c2044d218 Mon Sep 17 00:00:00 2001 From: gca Date: Wed, 12 Aug 2026 05:35:08 +0200 Subject: [PATCH 20/45] feat(app): add structured preparation scenario draft --- src/carnopy/app/field_ids.py | 7 + src/carnopy/app/scenario_draft.py | 636 +++++++++++++++++++++++++++++ src/carnopy/app/workflow_models.py | 68 +++ tests/test_app_scenario_draft.py | 258 ++++++++++++ tests/test_app_workflow_models.py | 33 ++ 5 files changed, 1002 insertions(+) create mode 100644 src/carnopy/app/scenario_draft.py create mode 100644 tests/test_app_scenario_draft.py diff --git a/src/carnopy/app/field_ids.py b/src/carnopy/app/field_ids.py index 25b1803..4e2d08b 100644 --- a/src/carnopy/app/field_ids.py +++ b/src/carnopy/app/field_ids.py @@ -30,6 +30,13 @@ 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: diff --git a/src/carnopy/app/scenario_draft.py b/src/carnopy/app/scenario_draft.py new file mode 100644 index 0000000..f2adef4 --- /dev/null +++ b/src/carnopy/app/scenario_draft.py @@ -0,0 +1,636 @@ +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_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/workflow_models.py b/src/carnopy/app/workflow_models.py index 677ea56..f080cec 100644 --- a/src/carnopy/app/workflow_models.py +++ b/src/carnopy/app/workflow_models.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from typing import Literal @@ -12,6 +14,7 @@ QPersistentModelIndex, Qt, Signal, + Slot, ) IssueOrigin = Literal["local", "schema", "source", "dependency", "plan", "runtime"] @@ -30,6 +33,71 @@ 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 WorkflowIssue: """One private, stable workflow issue projected to QML.""" 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_workflow_models.py b/tests/test_app_workflow_models.py index 3f5fab4..23bf1d1 100644 --- a/tests/test_app_workflow_models.py +++ b/tests/test_app_workflow_models.py @@ -24,6 +24,7 @@ SEVERITY_ROLE, WorkflowIssue, WorkflowIssueModel, + WorkflowListModel, ) @@ -109,6 +110,38 @@ def test_workflow_issue_model_count_changes_only_when_length_changes() -> None: 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 From c3c893123ca92fc341fddb3184f9e3811dc1a858 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 01:33:38 +0200 Subject: [PATCH 21/45] feat(app): manage committed preparation scenarios --- src/carnopy/app/preparation_draft.py | 221 +++++++++++++++++++++++++++ tests/test_app_config_controller.py | 37 +++++ tests/test_app_preparation_draft.py | 181 ++++++++++++++++++++++ 3 files changed, 439 insertions(+) diff --git a/src/carnopy/app/preparation_draft.py b/src/carnopy/app/preparation_draft.py index 179f7c0..521398e 100644 --- a/src/carnopy/app/preparation_draft.py +++ b/src/carnopy/app/preparation_draft.py @@ -19,6 +19,8 @@ PREPARATION_SOURCE_POLICY, PREPARATION_TARGETS, ) +from carnopy.app.scenario_draft import ScenarioDraft +from carnopy.app.workflow_models import WorkflowListModel DERIVED_FEATURES = ( "specific_volume", @@ -40,6 +42,7 @@ class PreparationDraft(QObject): 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: @@ -51,6 +54,7 @@ def __init__(self, parent: QObject | None = None) -> None: 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 @@ -73,6 +77,9 @@ def __init__(self, parent: QObject | None = None) -> None: 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]"' @@ -122,6 +129,38 @@ def get_baseline_model_choices(self) -> QObject: 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 @@ -537,8 +576,11 @@ def load_payload(self, payload: Mapping[str, object]) -> None: 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")) @@ -591,6 +633,11 @@ def load_payload(self, payload: Mapping[str, object]) -> None: 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: @@ -601,10 +648,14 @@ def load_payload(self, payload: Mapping[str, object]) -> None: 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 = () @@ -625,6 +676,7 @@ def clear(self) -> None: 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 @@ -635,6 +687,8 @@ def clear(self) -> None: 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(): @@ -643,7 +697,105 @@ def mark_baseline(self) -> None: 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_draft_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_draft_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 + try: + self._validated_payload(tuple(updated)) + except ValueError as exc: + self.message.emit(str(exc)) + return False + changed = tuple(updated) != self._scenarios + if changed: + self._scenarios = tuple(updated) + self._discard_active_scenario() + self.active_scenario_draft_changed.emit() + if changed: + 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() + 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: @@ -664,6 +816,7 @@ def payload(self) -> dict[str, Any]: ] 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"] = { @@ -702,6 +855,9 @@ def payload(self) -> dict[str, Any]: 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, @@ -711,6 +867,7 @@ def raw_state(self) -> tuple[object, ...]: tuple(self._categorical.items()), self._targets, self._auxiliary, + copy.deepcopy(self._scenarios), self._array_formats, self._array_dtype, self._include_auxiliary, @@ -907,6 +1064,32 @@ def _state_changed(self) -> None: 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 _role_model(self, role: str) -> DraftListModel | None: return { "numeric": self.numeric_choices, @@ -990,6 +1173,16 @@ def _refresh_models(self) -> None: ) 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 = { @@ -1125,3 +1318,31 @@ def _positive_integer(value: str, label: str) -> int: 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/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index 8be4f70..e591978 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -905,3 +905,40 @@ def test_preparation_output_and_quality_drafts_compose_into_global_document( 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() diff --git a/tests/test_app_preparation_draft.py b/tests/test_app_preparation_draft.py index de66df1..460c388 100644 --- a/tests/test_app_preparation_draft.py +++ b/tests/test_app_preparation_draft.py @@ -269,6 +269,187 @@ def test_clear_removes_document_state_but_retains_source_projection() -> None: 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_preparation_output_and_quality_settings_round_trip_completely() -> None: draft = PreparationDraft() draft.apply_capabilities(_capabilities(safetensors=True, analysis=True)) From e115aca17c786d7abd27e16e77f0f1316c092ea8 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 01:56:53 +0200 Subject: [PATCH 22/45] feat(app): guard preparation scenario lifecycle --- src/carnopy/app/config_controller.py | 40 +++++--- src/carnopy/app/desktop_controller.py | 89 ++++++++++++++---- src/carnopy/app/preparation_draft.py | 110 +++++++++++++++++++++- tests/test_app_config_controller.py | 74 +++++++++++++++ tests/test_app_desktop_controller.py | 105 +++++++++++++++++++++ tests/test_app_preparation_draft.py | 126 ++++++++++++++++++++++++++ 6 files changed, 505 insertions(+), 39 deletions(-) diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index 2bd323f..864d69a 100644 --- a/src/carnopy/app/config_controller.py +++ b/src/carnopy/app/config_controller.py @@ -108,6 +108,9 @@ def __init__( 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) @@ -217,7 +220,9 @@ def get_blocking_section(self) -> str: 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: + 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" @@ -262,6 +267,8 @@ def get_blocking_issue(self) -> str: 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() @@ -389,7 +396,7 @@ def set_workspace(self, value: object) -> None: workspace = value if isinstance(value, Workspace) else None changed = self.workspace != workspace if changed and ( - self._has_active_sweep_edit() or not self._lifecycle_allowed("workspace replacement") + self._has_active_workflow_edit() or not self._lifecycle_allowed("workspace replacement") ): return self.workspace = workspace @@ -416,7 +423,7 @@ def new_dataset(self, mode: str, discard_confirmed: bool = False) -> bool: if not self._lifecycle_allowed("New Dataset"): return False capabilities = self.capabilities - if self.workspace is None or capabilities is None or self._has_active_sweep_edit(): + 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.") @@ -442,7 +449,7 @@ def new_sweep(self, discard_confirmed: bool = False) -> bool: def import_dataset(self, path: str, discard_confirmed: bool = False) -> bool: if not self._lifecycle_allowed("Import"): return False - if self.workspace is None or self._has_active_sweep_edit(): + 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.") @@ -487,7 +494,7 @@ def request_save(self, allow_reformat: bool = False) -> bool: document is None or not self._locally_valid or self.coordinator.is_busy - or self._has_active_sweep_edit() + or self._has_active_workflow_edit() ): return False if document.source_path is None or not document.workspace_owned: @@ -510,7 +517,7 @@ def request_save_as(self, allow_reformat: bool = False) -> bool: self.document is None or not self._locally_valid or self.coordinator.is_busy - or self._has_active_sweep_edit() + or self._has_active_workflow_edit() ): return False return self._request_save_as(allow_reformat=allow_reformat) @@ -525,7 +532,7 @@ 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_sweep_edit(): + if self._has_active_workflow_edit(): return False if not self._awaiting_save_path: self._set_status("Save As is not awaiting a destination.") @@ -552,7 +559,7 @@ def reload_source(self, discard_confirmed: bool = False) -> bool: document is None or document.source_path is None or self.coordinator.is_busy - or self._has_active_sweep_edit() + or self._has_active_workflow_edit() ): return False if self.needs_discard_confirmation() and not discard_confirmed: @@ -595,7 +602,7 @@ def apply_coordinate_change(self, selected: str) -> bool: return self.dataset_draft.set_coordinate(selected) def open_document(self, document: ConfigurationDocument) -> bool: - if self._has_active_sweep_edit() or not self._lifecycle_allowed("document replacement"): + if self._has_active_workflow_edit() or not self._lifecycle_allowed("document replacement"): return False self.document = document self._reset_worker_validation("not_run") @@ -631,7 +638,7 @@ def open_document(self, document: ConfigurationDocument) -> bool: return True def clear_document(self, discard_confirmed: bool = False) -> bool: - if self._has_active_sweep_edit() or 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.") @@ -664,7 +671,7 @@ def execution_snapshot( and self.visualization_draft.get_locally_valid() ) ) - if not self._locally_valid or not drafts_valid or self._has_active_sweep_edit(): + 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) @@ -1118,10 +1125,15 @@ 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_sweep_edit() + return ( + self.visualization_draft.get_has_active_plot_edit() or self._has_active_workflow_edit() + ) - def _has_active_sweep_edit(self) -> bool: - return self.sweep_draft.get_has_active_comparison_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]: diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index 1687958..f1d0708 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -135,7 +135,7 @@ def __init__( self.preparation_workflow_controller.output_finalized.connect( lambda _path: self.inspection_controller.refresh_sources() ) - self.configuration_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, @@ -232,6 +232,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( @@ -367,11 +369,21 @@ def get_has_active_sweep_edit(self) -> bool: 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() or self.get_has_active_sweep_edit() + or self.get_has_active_preparation_edit() ) hasAnyTransientEdit = Property( @@ -487,13 +499,13 @@ 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_configuration_lifecycle("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_active_plot_edit("New Model Sweep"): + if not self._guard_configuration_lifecycle("New Model Sweep"): return False return self.configuration_controller.new_sweep(discard_confirmed) @@ -503,7 +515,7 @@ def request_import_configuration( path: str, discard_confirmed: bool = False, ) -> bool: - if not self._guard_active_plot_edit("Open Configuration"): + if not self._guard_configuration_lifecycle("Open Configuration"): return False return self.configuration_controller.import_configuration( _local_path(path), @@ -512,7 +524,7 @@ def request_import_configuration( @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_configuration_lifecycle("Import"): return False return self.configuration_controller.import_dataset( _local_path(path), @@ -521,19 +533,19 @@ def request_import_dataset(self, path: str, discard_confirmed: bool = False) -> @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_configuration_lifecycle("Save"): return False 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_configuration_lifecycle("Save As"): return False 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_configuration_lifecycle("Validation"): return False return self.configuration_controller.request_validation() @@ -706,7 +718,7 @@ 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_configuration_lifecycle("Save As"): return False return self.configuration_controller.save_path_selected(_local_path(path)) @@ -716,24 +728,24 @@ def request_cancel_save_path(self) -> None: @Slot(str, name="requestConfirmReformat") def request_confirm_reformat(self, action: str) -> None: - if self._guard_active_plot_edit("Save"): + if self._guard_configuration_lifecycle("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_configuration_lifecycle("Reload"): return False 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_configuration_lifecycle("Close Configuration"): return False 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", "sweep", "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.") @@ -744,7 +756,7 @@ 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._guard_configuration_lifecycle("dataset mode change"): return False if mode == self.dataset_draft.get_mode_name(): return False @@ -756,7 +768,7 @@ 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._guard_configuration_lifecycle("dataset coordinate change"): return False if axis == self.dataset_draft.get_coordinate_name(): return False @@ -771,7 +783,7 @@ 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._guard_configuration_lifecycle("dataset replacement"): self._pending_dataset_decision = None self.datasetDecisionChanged.emit() return False @@ -1377,7 +1389,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 @@ -1438,6 +1450,8 @@ def request_shutdown(self) -> bool: 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?" @@ -1496,6 +1510,11 @@ def confirm_transient_edit_shutdown(self, discard_confirmed: bool) -> bool: 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.closeWindowRequested.emit() return True @@ -1504,7 +1523,7 @@ def confirm_shutdown(self, discard_confirmed: bool) -> bool: if not discard_confirmed: self._shutdown_discard_confirmed = False 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 @@ -1605,14 +1624,19 @@ def _active_plot_state_changed(self) -> None: 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_configuration_lifecycle(self, operation: str = "this operation") -> bool: + return self._guard_active_plot_edit(operation) and self._guard_workflow_nested_edit( + operation + ) + def _guard_active_plot_edit(self, operation: str = "this operation") -> bool: if self.visualization_draft.get_active_plot_draft() is None: return True @@ -1626,6 +1650,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 diff --git a/src/carnopy/app/preparation_draft.py b/src/carnopy/app/preparation_draft.py index 521398e..aeca265 100644 --- a/src/carnopy/app/preparation_draft.py +++ b/src/carnopy/app/preparation_draft.py @@ -16,6 +16,7 @@ PREPARATION_FEATURES, PREPARATION_MATRIX_DIAGNOSTICS, PREPARATION_OUTPUTS, + PREPARATION_SCENARIO_ACTIVE, PREPARATION_SOURCE_POLICY, PREPARATION_TARGETS, ) @@ -460,6 +461,8 @@ def get_source_issue(self) -> str: + ", ".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) @@ -478,7 +481,37 @@ def get_source_issue(self) -> str: 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 "" @@ -501,10 +534,20 @@ def get_first_invalid_field(self) -> str: 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 @@ -706,7 +749,9 @@ def begin_add_scenario(self) -> bool: 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) @@ -719,7 +764,9 @@ def begin_edit_scenario(self, row: int) -> bool: 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) @@ -743,18 +790,24 @@ def commit_scenario(self) -> bool: 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 - changed = tuple(updated) != self._scenarios - if changed: - self._scenarios = tuple(updated) + self._scenarios = tuple(updated) self._discard_active_scenario() self.active_scenario_draft_changed.emit() - if changed: - self._state_changed() + self._state_changed() return True @Slot(result=bool) @@ -763,6 +816,7 @@ def cancel_scenario(self) -> bool: return False self._discard_active_scenario() self.active_scenario_draft_changed.emit() + self.validity_changed.emit() return True @Slot(int, result=bool) @@ -1090,6 +1144,52 @@ def _scenario_field_choices(self) -> tuple[str, ...]: ) ) + 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, diff --git a/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index e591978..3fe8745 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -942,3 +942,77 @@ def test_committed_preparation_scenario_composes_into_global_document( 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.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_desktop_controller.py b/tests/test_app_desktop_controller.py index 5a15288..eae79d0 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -438,6 +438,37 @@ def test_qml_shutdown_explicitly_cancels_a_transient_sweep_edit_before_close( 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] = [] + desktop.transientEditShutdownConfirmationRequested.connect(confirmations.append) + desktop.closeWindowRequested.connect(lambda: close_requests.append("close")) + monkeypatch.setattr(desktop, "get_has_active_preparation_edit", lambda: True) + monkeypatch.setattr( + desktop.configuration_controller.preparation_draft, + "cancel_scenario", + lambda: cancellations.append("scenario") or True, + ) + + 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 desktop.confirm_transient_edit_shutdown(True) + assert cancellations == ["scenario"] + assert close_requests == ["close"] + + def test_configuration_attention_facade_accepts_only_stable_sections( tmp_path: Path, application: QCoreApplication, @@ -451,12 +482,18 @@ def test_configuration_attention_facade_accepts_only_stable_sections( 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() @@ -1037,6 +1074,74 @@ 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", + "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_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, diff --git a/tests/test_app_preparation_draft.py b/tests/test_app_preparation_draft.py index 460c388..75458c0 100644 --- a/tests/test_app_preparation_draft.py +++ b/tests/test_app_preparation_draft.py @@ -71,6 +71,7 @@ def _profile(*, complete: bool = True) -> dict[str, object]: 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")], @@ -80,6 +81,10 @@ def _profile(*, complete: bool = True) -> dict[str, object]: "fluid": ["Propane"], }, "derived_features": derived, + "model_holdout": { + "available": False, + "reason": "Model holdout scenarios require a model-sweep source.", + }, "reference_context": {"compatible": True, "reason": ""}, } @@ -450,6 +455,127 @@ def test_clear_discards_active_scenario_without_committing_it() -> None: 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)) From 9208255a417da2e63d46f83564184573b0d25db1 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 02:10:30 +0200 Subject: [PATCH 23/45] feat(app): integrate preparation planning inputs --- src/carnopy/app/desktop_controller.py | 1 + src/carnopy/app/workflow_controller.py | 54 ++++- tests/test_app_desktop_controller.py | 4 + tests/test_app_workflow_controller.py | 271 ++++++++++++++++++++++++- 4 files changed, 322 insertions(+), 8 deletions(-) diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index f1d0708..029a675 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -85,6 +85,7 @@ def __init__( 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 diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index ab577eb..500ad78 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -378,6 +378,7 @@ def _plan_blocking_issues(self) -> tuple[WorkflowIssue, ...]: ), ) ) + issues.extend(self._workflow_input_issues()) if issue := self._worker_availability_issue(): issues.append(issue) return tuple(issues) @@ -428,10 +429,14 @@ def _execution_blocking_issues(self) -> tuple[WorkflowIssue, ...]: ) 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( @@ -505,7 +510,7 @@ def _worker_availability_issue(self) -> WorkflowIssue | None: def _blocking_issue( self, *, - origin: Literal["local", "source", "plan", "runtime"], + origin: Literal["local", "source", "dependency", "plan", "runtime"], code: str, message: str, section: str, @@ -566,6 +571,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 @@ -1021,12 +1029,26 @@ def __init__( 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._source_binding: _PreparationSourceBinding | None = None self._source_binding_issue = "" 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 get_has_bound_source(self) -> bool: @@ -1169,6 +1191,34 @@ def _plan_context(self) -> dict[str, object]: 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 _plan_result_matches_current_context(self, result: dict[str, object]) -> bool: binding = self._source_binding source_revision = result.get("source_revision") diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index eae79d0..632884b 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -114,6 +114,10 @@ def test_desktop_controller_owns_one_composition_and_preserves_settings_identity 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 diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index 664db85..f91657a 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -208,6 +208,52 @@ def _saved_sweep_document(workspace: Workspace) -> ConfigurationDocument: ) +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( @@ -256,6 +302,7 @@ def _accept_preparation_inspection( source: Path, *, revision: str, + complete_profile: bool = True, ) -> dict[str, object]: descriptor: dict[str, object] = { "source_path": str(source.resolve()), @@ -264,6 +311,21 @@ 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()), @@ -279,12 +341,42 @@ def _accept_preparation_inspection( "available_models": ["heos"], "declared_models": [], "reference_model": "heos", - "numeric_candidates": [], - "target_candidates": [], - "categorical_candidates": [], - "auxiliary_candidates": [], - "observed_category_values": {}, - "derived_features": [], + "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.", @@ -437,6 +529,173 @@ def test_sweep_planning_uses_the_global_configuration_snapshot( coordinator.shutdown() +def test_preparation_planning_uses_global_configuration_and_bound_source( + 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) + 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) + 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.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_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 configuration.preparation_draft.set_allow_partial_sweep(False) + assert not configuration.get_dirty() + assert controller.get_plan_current() + coordinator.shutdown() + + +def test_preparation_planning_blocks_source_and_dependency_issues_before_worker( + 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, 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 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() + + 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() + + 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 + + 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_sweep_execution_retains_its_global_snapshot_while_the_draft_changes( tmp_path: Path, application: QCoreApplication, From 54eb3a5ee190a20ee6d8389c7b86a31f2c715a35 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 02:37:11 +0200 Subject: [PATCH 24/45] feat(app): project preparation plan evidence --- src/carnopy/app/workflow_controller.py | 22 +- src/carnopy/app/workflow_models.py | 885 ++++++++++++++++++++++++- tests/test_app_workflow_controller.py | 253 +++++++ tests/test_app_workflow_models.py | 226 +++++++ 4 files changed, 1384 insertions(+), 2 deletions(-) diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index 500ad78..6e5916d 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -26,7 +26,12 @@ RequestReservation, RequestSession, ) -from carnopy.app.workflow_models import WorkflowIssue, WorkflowIssueModel +from carnopy.app.workflow_models import ( + PreparationPlanModel, + PreparationPlanProjection, + WorkflowIssue, + WorkflowIssueModel, +) from carnopy.app.workspace import Workspace if TYPE_CHECKING: @@ -1041,6 +1046,7 @@ def __init__( self.inspection = inspection 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( @@ -1051,6 +1057,11 @@ def __init__( ) self._refresh_typed_projections() + 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 @@ -1219,6 +1230,15 @@ def _workflow_input_issues(self) -> tuple[WorkflowIssue, ...]: ) 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: binding = self._source_binding source_revision = result.get("source_revision") diff --git a/src/carnopy/app/workflow_models.py b/src/carnopy/app/workflow_models.py index f080cec..fd8855b 100644 --- a/src/carnopy/app/workflow_models.py +++ b/src/carnopy/app/workflow_models.py @@ -3,7 +3,7 @@ import copy from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass -from typing import Literal +from typing import Literal, cast from PySide6.QtCore import ( Property, @@ -98,6 +98,889 @@ 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.""" diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index f91657a..a707f69 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -272,6 +272,7 @@ def _finish_plan( digest: str, plan_id: str = "b" * 64, source_revision: dict[str, object] | None = None, + preparation_projection: dict[str, object] | None = None, ) -> None: payload: dict[str, object] = { "plan_id": plan_id, @@ -279,9 +280,154 @@ def _finish_plan( } 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, @@ -696,6 +842,113 @@ def test_preparation_planning_blocks_source_and_dependency_issues_before_worker( coordinator.shutdown() +def test_preparation_plan_projects_typed_evidence_and_retains_it_while_stale( + tmp_path: Path, + application: QCoreApplication, +) -> None: + del application + workspace = initialize_workspace(tmp_path / "workspace") + replacement_workspace = initialize_workspace(tmp_path / "replacement-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": {}, + }, + preparation_projection=_rich_preparation_plan_projection(), + ) + + 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() + + 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() + + assert controller.plan() + transport.finish( + payload={ + "plan_id": "c" * 64, + "configuration_sha256": digest, + "source_revision": { + "inspection_revision": revision, + "inspection_descriptor": descriptor, + "consumed_source": {}, + }, + } + ) + 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 + + 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_sweep_execution_retains_its_global_snapshot_while_the_draft_changes( tmp_path: Path, application: QCoreApplication, diff --git a/tests/test_app_workflow_models.py b/tests/test_app_workflow_models.py index 23bf1d1..5163121 100644 --- a/tests/test_app_workflow_models.py +++ b/tests/test_app_workflow_models.py @@ -22,12 +22,238 @@ 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", From c3372cbaa6005b2a93c32c01ab472c388a628879 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 02:49:47 +0200 Subject: [PATCH 25/45] feat(app): integrate preparation execution control --- src/carnopy/app/config_controller.py | 4 +- src/carnopy/app/desktop_controller.py | 54 ++++++-- tests/test_app_desktop_controller.py | 83 +++++++++++- tests/test_app_workflow_controller.py | 179 ++++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 13 deletions(-) diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index 864d69a..e7821b4 100644 --- a/src/carnopy/app/config_controller.py +++ b/src/carnopy/app/config_controller.py @@ -337,8 +337,8 @@ def get_can_edit(self) -> bool: if not self.get_editor_available(): return False session = getattr(self.coordinator, "active_session", None) - if session is not None and session.owner == "sweep": - return bool(session.request_type == "execute_sweep") + 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) diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index 029a675..75b7575 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -121,6 +121,9 @@ def __init__( ) 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() ) @@ -588,7 +591,9 @@ def request_workflow_force_stop(self, workflow: str) -> bool: @Slot(str, result=bool, name="requestWorkflowInspectResult") def request_workflow_inspect_result(self, workflow: str) -> bool: - controller = self._workflow_controller(workflow) + controller = ( + self.sweep_workflow_controller if workflow in {"sweep", "model_sweep"} else None + ) output = "" if controller is None else controller.get_result_output_directory() if not output: self.activityActionFailed.emit( @@ -1423,6 +1428,15 @@ def request_shutdown(self) -> bool: "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" @@ -1483,6 +1497,10 @@ def confirm_busy_shutdown(self, confirmed: bool) -> bool: 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.workspace_controller.report_error( @@ -1572,7 +1590,11 @@ def _request_state_changed(self, busy: bool) -> None: def _continue_pending_busy_shutdown(self) -> None: mode = self._pending_busy_shutdown - if mode not in {"generation_waiting", "sweep_waiting"}: + if mode not in { + "generation_waiting", + "sweep_waiting", + "preparation_waiting", + }: return session = self.request_coordinator.active_session if session is None: @@ -1588,14 +1610,25 @@ def _continue_pending_busy_shutdown(self) -> None: 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.owner != "sweep" - or session.request_type != "execute_sweep" - or not self.sweep_workflow_controller.get_cancellation_available() + session.owner != "preparation" + or session.request_type != "execute_preparation" + or not self.preparation_workflow_controller.get_cancellation_available() ): return - self._pending_busy_shutdown = "sweep" - if not self.sweep_workflow_controller.cancel(): + self._pending_busy_shutdown = "preparation" + if not self.preparation_workflow_controller.cancel(): self._pending_busy_shutdown = mode def _complete_busy_shutdown(self) -> None: @@ -1685,9 +1718,14 @@ 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 | None: + 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: diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 632884b..200f299 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -343,6 +343,51 @@ def test_qml_shutdown_cancels_sweep_then_closes_after_safe_completion( 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, @@ -540,7 +585,7 @@ def test_execution_facade_routes_qml_intent_to_the_authoritative_controller( assert desktop.shutdown() -def test_sweep_workflow_facade_routes_only_the_integrated_workflow( +def test_workflow_facade_routes_sweep_and_preparation_control( tmp_path: Path, application: QCoreApplication, monkeypatch: pytest.MonkeyPatch, @@ -568,14 +613,46 @@ def test_sweep_workflow_facade_routes_only_the_integrated_workflow( "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 not desktop.request_workflow_plan("preparation") + 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"] + assert calls == [ + "plan", + "execute", + "cancel", + "force_stop", + "preparation_plan", + "preparation_execute", + "preparation_cancel", + "preparation_force_stop", + ] assert desktop.shutdown() diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index a707f69..2e6104f 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -949,6 +949,185 @@ def test_preparation_plan_projects_typed_evidence_and_retains_it_while_stale( coordinator.shutdown() +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") + 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]) + 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, From 439930e245ed7642fddc8159ae635bea23e3e768 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 03:01:50 +0200 Subject: [PATCH 26/45] feat(app): integrate preparation result lifecycle --- src/carnopy/app/desktop_controller.py | 4 +- tests/test_app_desktop_controller.py | 45 +++++++++++-- tests/test_app_workflow_controller.py | 93 +++++++++++++++++++++++++-- 3 files changed, 125 insertions(+), 17 deletions(-) diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index 75b7575..1bcdde6 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -591,9 +591,7 @@ def request_workflow_force_stop(self, workflow: str) -> bool: @Slot(str, result=bool, name="requestWorkflowInspectResult") def request_workflow_inspect_result(self, workflow: str) -> bool: - controller = ( - self.sweep_workflow_controller if workflow in {"sweep", "model_sweep"} else None - ) + controller = self._workflow_controller(workflow) output = "" if controller is None else controller.get_result_output_directory() if not output: self.activityActionFailed.emit( diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 200f299..6b4c956 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -723,36 +723,58 @@ def test_sweep_editor_facade_enforces_document_and_worker_edit_guards( assert desktop.shutdown() -def test_sweep_result_handoff_inspects_the_exact_finalized_output( +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")) - output = tmp_path / "workspace" / "outputs" / "sweep-run" + 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.sweep_workflow_controller, "get_result_output_directory", - lambda: str(output), + 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 desktop.request_workflow_inspect_result("sweep") - assert inspected == [str(output)] - assert navigation == [("inspect", "")] + 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("preparation") + assert not desktop.request_workflow_inspect_result("unknown") assert failures == [ ( "Inspect Result", @@ -837,9 +859,18 @@ def test_bound_preparation_profile_is_the_only_profile_applied_to_the_draft( 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")) @@ -854,7 +885,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" diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index 2e6104f..3fbd2f9 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -271,6 +271,8 @@ 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: @@ -278,6 +280,10 @@ def _finish_plan( "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( @@ -1941,24 +1947,32 @@ def test_preparation_binding_refresh_clear_and_workspace_lifecycle_are_explicit( coordinator.shutdown() -def test_preparation_result_keeps_the_source_context_used_by_execution( +def test_preparation_result_and_activity_keep_exact_execution_identities( tmp_path: Path, application: QCoreApplication, ) -> None: del application workspace = initialize_workspace(tmp_path / "workspace") - config = _config(workspace, "preparation.yaml") + 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() - coordinator, transport = coordinator_for() inspection = InspectionController(coordinator) - controller = PreparationWorkflowController(coordinator, inspection) + controller = PreparationWorkflowController( + coordinator, + inspection, + configuration_controller=configuration, + ) controller.set_workspace(workspace) - assert controller.load_config(config) - digest = _finish_load(transport, config) revision = "a" * 64 descriptor = _accept_preparation_inspection( inspection, @@ -1966,10 +1980,20 @@ def test_preparation_result_keeps_the_source_context_used_by_execution( 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, @@ -1980,6 +2004,11 @@ def test_preparation_result_keeps_the_source_context_used_by_execution( 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, @@ -1992,11 +2021,24 @@ def test_preparation_result_keeps_the_source_context_used_by_execution( 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" + 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", @@ -2004,6 +2046,9 @@ def test_preparation_result_keeps_the_source_context_used_by_execution( "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" @@ -2016,6 +2061,40 @@ def test_preparation_result_keeps_the_source_context_used_by_execution( 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() From 60ce67742425d269c6bc0199a5433d1018f018c1 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 03:09:25 +0200 Subject: [PATCH 27/45] feat(app): expose preparation editor commands --- src/carnopy/app/desktop_controller.py | 109 ++++++++++++++++++++++++ tests/test_app_desktop_controller.py | 114 ++++++++++++++++++++++++++ 2 files changed, 223 insertions(+) diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index 1bcdde6..b8ae2ec 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -1078,6 +1078,105 @@ def request_sweep_comparison_filter_remove(self, candidate: QObject, row: int) - 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(bool, name="requestVisualizationEnabled") def request_visualization_enabled(self, enabled: bool) -> None: if self._guard_active_plot_edit("visualization enable or disable"): @@ -1258,6 +1357,16 @@ def _can_edit_sweep_document(self) -> bool: ) return False + def _can_edit_preparation_document(self) -> bool: + if self.configuration_controller.get_document_kind() != "preparation": + return False + if self.configuration_controller.get_can_edit(): + return True + self.workspace_controller.report_error( + "Wait for the active worker request before editing the ML Preparation configuration." + ) + 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) diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 6b4c956..b2df4fa 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -723,6 +723,120 @@ def test_sweep_editor_facade_enforces_document_and_worker_edit_guards( assert desktop.shutdown() +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")) + 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( + 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) + + desktop.request_preparation_role_selection("target", "specific_enthalpy", True) + assert selections == [] + + monkeypatch.setattr(controller, "get_document_kind", lambda: "preparation") + desktop.request_preparation_role_selection("target", "specific_enthalpy", True) + assert selections == [("target", "specific_enthalpy", True)] + + 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_workflow_result_handoff_inspects_exact_finalized_outputs_without_rebinding( tmp_path: Path, application: QCoreApplication, From 90be6c70006ac44c450bd0f39a3e043ac34e2223 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 03:27:18 +0200 Subject: [PATCH 28/45] feat(app): add structured preparation scenario editor --- scripts/check_distribution.py | 1 + src/carnopy/app/desktop_controller.py | 201 +++++ .../components/PreparationScenarioEditor.qml | 704 ++++++++++++++++++ src/carnopy/app/qml/Carnopy/qmldir | 1 + src/carnopy/app/qml_resources.py | 1 + src/carnopy/app/scenario_draft.py | 5 + tests/test_app_desktop_controller.py | 133 ++++ tests/test_app_qml_preparation.py | 233 ++++++ tests/test_packaging_metadata.py | 1 + 9 files changed, 1280 insertions(+) create mode 100644 src/carnopy/app/qml/Carnopy/components/PreparationScenarioEditor.qml create mode 100644 tests/test_app_qml_preparation.py diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py index 12c622e..b3578ef 100644 --- a/scripts/check_distribution.py +++ b/scripts/check_distribution.py @@ -49,6 +49,7 @@ "qml/Carnopy/components/SearchableChoiceList.qml", "qml/Carnopy/components/CommandBar.qml", "qml/Carnopy/components/ComparisonPlotEditor.qml", + "qml/Carnopy/components/PreparationScenarioEditor.qml", "qml/Carnopy/components/ContextInspector.qml", "qml/Carnopy/components/ActivityContextInspector.qml", "qml/Carnopy/components/InspectionContextInspector.qml", diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index b8ae2ec..5582be8 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -19,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 ( @@ -1177,6 +1178,202 @@ def request_preparation_baseline_model_selection( 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"): @@ -1389,6 +1586,10 @@ def _owned_sweep_comparison_mapping( 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: active_drafts = ( self.visualization_draft.get_active_plot_draft(), 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..304834e --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/components/PreparationScenarioEditor.qml @@ -0,0 +1,704 @@ +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 + + 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.desktopController.requestPreparationScenarioFieldChange( + 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.desktopController.requestPreparationScenarioFieldChange( + 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.desktopController.requestPreparationScenarioFieldChange(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.desktopController.requestPreparationScenarioFieldChange(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.desktopController.requestPreparationScenarioPartition( + root.draft, partitionRow.partition, text) + selectByMouse: true + text: String(partitionRow.ratio) + } + + AppButton { + compact: true + enabled: !root.locked + onClicked: root.desktopController.requestPreparationScenarioRemovePartition( + 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.desktopController.requestPreparationScenarioPartition(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.desktopController.requestPreparationScenarioRemoveHoldout( + 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.desktopController.requestPreparationScenarioCategoricalHoldout(root.draft, + partition, + categoricalValues.text); + else if (root.draft.kind === "range_holdout") + root.desktopController.requestPreparationScenarioRangeHoldout(root.draft, + partition, + minimumValue.text, + maximumValue.text); + else + root.desktopController.requestPreparationScenarioCoordinateHoldout(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.desktopController.requestPreparationScenarioStrata(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.desktopController.requestPreparationScenarioNumericBins(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.desktopController.requestPreparationScenarioRemoveNumericBins( + 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.desktopController.requestPreparationScenarioTransformationMove( + root.draft, transformationRow.index, transformationRow.index - 1) + text: qsTr("Up") + } + + AppButton { + compact: true + enabled: !root.locked && transformationRow.index + 1 < transformationsList.count + onClicked: root.desktopController.requestPreparationScenarioTransformationMove( + root.draft, transformationRow.index, transformationRow.index + 1) + text: qsTr("Down") + } + + AppButton { + compact: true + enabled: !root.locked + onClicked: root.desktopController.requestPreparationScenarioTransformationRemove( + 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.desktopController.requestPreparationScenarioTransformationAdd( + 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.desktopController.requestPreparationScenarioKindChange(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/qmldir b/src/carnopy/app/qml/Carnopy/qmldir index 6bfb0d3..6cce668 100644 --- a/src/carnopy/app/qml/Carnopy/qmldir +++ b/src/carnopy/app/qml/Carnopy/qmldir @@ -9,6 +9,7 @@ BlockingBanner 1.0 components/BlockingBanner.qml Card 1.0 components/Card.qml CommandBar 1.0 components/CommandBar.qml ComparisonPlotEditor 1.0 components/ComparisonPlotEditor.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 diff --git a/src/carnopy/app/qml_resources.py b/src/carnopy/app/qml_resources.py index 012584c..1b393cf 100644 --- a/src/carnopy/app/qml_resources.py +++ b/src/carnopy/app/qml_resources.py @@ -35,6 +35,7 @@ "qml/Carnopy/components/SearchableChoiceList.qml", "qml/Carnopy/components/CommandBar.qml", "qml/Carnopy/components/ComparisonPlotEditor.qml", + "qml/Carnopy/components/PreparationScenarioEditor.qml", "qml/Carnopy/components/ContextInspector.qml", "qml/Carnopy/components/InspectionContextInspector.qml", "qml/Carnopy/components/ActivityContextInspector.qml", diff --git a/src/carnopy/app/scenario_draft.py b/src/carnopy/app/scenario_draft.py index f2adef4..4f566ba 100644 --- a/src/carnopy/app/scenario_draft.py +++ b/src/carnopy/app/scenario_draft.py @@ -151,6 +151,11 @@ def get_transform_method_choices(self) -> list[str]: 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 diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index b2df4fa..bdf6b42 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, @@ -837,6 +838,138 @@ def test_preparation_editor_facade_enforces_document_and_worker_edit_guards( assert desktop.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")) + 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.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", + ) + 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), + ] + + desktop.request_preparation_scenario_field_change(foreign, "name", "foreign") + assert calls[-1] == ("move_transformation", 1, 0) + + 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_workflow_result_handoff_inspects_exact_finalized_outputs_without_rebinding( tmp_path: Path, application: QCoreApplication, diff --git a/tests/test_app_qml_preparation.py b/tests/test_app_qml_preparation.py new file mode 100644 index 0000000..1251f45 --- /dev/null +++ b/tests/test_app_qml_preparation.py @@ -0,0 +1,233 @@ +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.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 + +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 + + +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 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 "requestPreparationScenario" 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() diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 3bdeb5f..916e257 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -72,6 +72,7 @@ def test_qml_runtime_is_public_and_resources_live_in_the_app_package() -> None: "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", From ecea4e4f6df1a637c56f31f2f54560e0aca1f00c Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 03:56:46 +0200 Subject: [PATCH 29/45] feat(app): add structured preparation editor page --- scripts/check_distribution.py | 1 + src/carnopy/app/preparation_draft.py | 9 + .../app/qml/Carnopy/pages/PreparationPage.qml | 1069 +++++++++++++++++ src/carnopy/app/qml/Carnopy/qmldir | 1 + src/carnopy/app/qml_resources.py | 1 + tests/test_app_preparation_draft.py | 3 + tests/test_app_qml_preparation.py | 174 +++ tests/test_app_qml_runtime.py | 2 +- tests/test_packaging_metadata.py | 1 + 9 files changed, 1260 insertions(+), 1 deletion(-) create mode 100644 src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py index b3578ef..2f9ec11 100644 --- a/scripts/check_distribution.py +++ b/scripts/check_distribution.py @@ -76,6 +76,7 @@ "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/preparation_draft.py b/src/carnopy/app/preparation_draft.py index aeca265..23dca1b 100644 --- a/src/carnopy/app/preparation_draft.py +++ b/src/carnopy/app/preparation_draft.py @@ -385,6 +385,15 @@ def get_baseline_available(self) -> bool: 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 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..00caf37 --- /dev/null +++ b/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml @@ -0,0 +1,1069 @@ +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 categoryModeDialogRequested + signal inspectSourceRequested + 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 + + AppButton { + objectName: "preparationOpenWorkspaceButton" + onClicked: root.workspaceRequested() + text: qsTr("Open Workspace") + tone: "primary" + } + } + + 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.desktopController.requestBindInspectedPreparationSource() + 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.desktopController.requestClearPreparationSource(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.desktopController.requestPreparationRoleSelection( + "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.desktopController.requestPreparationRoleSelection( + "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.desktopController.requestPreparationRoleSelection( + "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.desktopController.requestPreparationRoleSelection( + "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.desktopController.requestPreparationCategoricalSelection( + 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.desktopController.requestPreparationCategoryMode( + 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.desktopController.requestPreparationExplicitCategories( + 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.desktopController.requestPreparationBooleanField( + "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.desktopController.requestPreparationAddScenario() + 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.desktopController.requestPreparationEditScenario( + 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.desktopController.requestPreparationMoveScenario( + 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.desktopController.requestPreparationMoveScenario( + 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.desktopController.requestPreparationRemoveScenario( + 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.desktopController.requestPreparationCancelScenario() + onCommitRequested: root.desktopController.requestPreparationCommitScenario() + } + } + } + + 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.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.desktopController.requestPreparationBooleanField( + "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.desktopController.requestPreparationArrayFormatSelection( + 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.desktopController.requestPreparationTextField( + "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.desktopController.requestPreparationBooleanField( + "include_auxiliary", checked) + text: qsTr("Include auxiliary fields in arrays") + visible: root.preparationDraft.arrayOutputsEnabled + } + } + + Card { + 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.desktopController.requestPreparationBooleanField( + "matrix_diagnostics", checked) + text: qsTr("Matrix diagnostics") + } + + GridLayout { + Layout.fillWidth: true + columns: width >= 520 ? 2 : 1 + objectName: "preparationMatrixSettingsGrid" + visible: root.preparationDraft.matrixDiagnosticsEnabled + + TextField { + Accessible.name: qsTr("Correlation threshold") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationCorrelationThreshold" + onEditingFinished: root.desktopController.requestPreparationTextField( + "correlation_threshold", text) + placeholderText: qsTr("Correlation threshold") + selectByMouse: true + text: root.preparationDraft.correlationThreshold + } + + TextField { + Accessible.name: qsTr("Near-constant relative spread") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationNearConstantSpread" + onEditingFinished: root.desktopController.requestPreparationTextField( + "near_constant_relative_spread", text) + placeholderText: qsTr("Near-constant spread") + 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.desktopController.requestPreparationBooleanField( + "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.desktopController.requestPreparationBaselineModelSelection( + 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 + + TextField { + Accessible.name: qsTr("Baseline random seed") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationBaselineSeed" + onEditingFinished: root.desktopController.requestPreparationTextField( + "baseline_random_seed", text) + placeholderText: qsTr("Random seed") + selectByMouse: true + text: root.preparationDraft.baselineRandomSeed + } + + TextField { + Accessible.name: qsTr("Ridge alpha") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationRidgeAlpha" + onEditingFinished: root.desktopController.requestPreparationTextField( + "ridge_alpha", text) + placeholderText: qsTr("Ridge alpha") + selectByMouse: true + text: root.preparationDraft.ridgeAlpha + } + + TextField { + Accessible.name: qsTr("Histogram maximum iterations") + Layout.fillWidth: true + enabled: !root.locked + objectName: "preparationHistogramIterations" + onEditingFinished: root.desktopController.requestPreparationTextField( + "histogram_max_iterations", text) + placeholderText: qsTr("Maximum iterations") + 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.desktopController.requestPreparationCategoryMode(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/qmldir b/src/carnopy/app/qml/Carnopy/qmldir index 6cce668..98cd0a1 100644 --- a/src/carnopy/app/qml/Carnopy/qmldir +++ b/src/carnopy/app/qml/Carnopy/qmldir @@ -43,3 +43,4 @@ 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 1b393cf..6a14a3f 100644 --- a/src/carnopy/app/qml_resources.py +++ b/src/carnopy/app/qml_resources.py @@ -59,6 +59,7 @@ "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/tests/test_app_preparation_draft.py b/tests/test_app_preparation_draft.py index 75458c0..7a306b2 100644 --- a/tests/test_app_preparation_draft.py +++ b/tests/test_app_preparation_draft.py @@ -667,6 +667,8 @@ def test_unavailable_optional_features_cannot_be_newly_selected() -> None: 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) @@ -694,6 +696,7 @@ def test_capability_refresh_changes_dependency_projection_without_dirtying_yaml( 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() diff --git a/tests/test_app_qml_preparation.py b/tests/test_app_qml_preparation.py index 1251f45..67a9530 100644 --- a/tests/test_app_qml_preparation.py +++ b/tests/test_app_qml_preparation.py @@ -3,8 +3,10 @@ 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") @@ -14,10 +16,12 @@ 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] @@ -76,6 +80,42 @@ def scenario_editor(runtime: QmlApplicationRuntime) -> Iterator[QQuickItem]: 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()) @@ -231,3 +271,137 @@ def test_preparation_scenario_qml_resource_and_controller_boundary_are_explicit( assert ".setPartition(" not in source assert ".addTransformation(" not in source assert "yaml" not in source.casefold() + + +def test_hidden_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_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 "requestPreparation" 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 "PreparationAuditView" not in source + assert "TextArea" not in source diff --git a/tests/test_app_qml_runtime.py b/tests/test_app_qml_runtime.py index fb89504..1978df1 100644 --- a/tests/test_app_qml_runtime.py +++ b/tests/test_app_qml_runtime.py @@ -642,4 +642,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 43 file(s).\n" + assert completed.stdout == "QML checks passed for 45 file(s).\n" diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 916e257..3a8d986 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -67,6 +67,7 @@ def test_qml_runtime_is_public_and_resources_live_in_the_app_package() -> None: "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", From 0673f0bc9365f15d6f8b5d423969c9aced57f41e Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 17:40:52 +0200 Subject: [PATCH 30/45] docs(gui2): update docs, record Stage 5 unit 18 checkpoint --- DESKTOP_ARCHITECTURE.md | 294 +++++++++++++++++----- GUI2_PLAN.md | 59 ++++- ML_PREPARATION_ROADMAP.md | 28 +++ README.md | 33 ++- docs/agent-guides/SCIENTIFIC_CONTRACTS.md | 15 +- tests/test_packaging_metadata.py | 7 +- 6 files changed, 361 insertions(+), 75 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index e38cef3..b948a72 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -32,15 +32,23 @@ 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 and tested + as hidden components at the Stage 5 Unit 18 checkpoint, but normal + Preparation navigation and creation remain disabled until Unit 19 completes + shell integration; - 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; @@ -124,11 +132,14 @@ not a second scientific implementation. | +------------------+------------------+ | | | - WorkspaceController DatasetConfigController QmlSettingsController + WorkspaceController ConfigurationController QmlSettingsController | | - | DatasetDraft + VisualizationDraft + | DatasetDraft + VisualizationDraft + | SweepDraft + PreparationDraft | | +------- DatasetExecutionController + +------- SweepWorkflowController + +------- PreparationWorkflowController | +------------ InspectionController | | @@ -163,9 +174,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 @@ -243,6 +257,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,6 +296,8 @@ 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; @@ -282,6 +327,15 @@ preview is queued after an explicit successful inspection because the request coordinator releases its active session only after delivering the terminal result. +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 +526,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 +568,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 +651,47 @@ 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 hidden Unit 18 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 +725,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 @@ -679,6 +788,26 @@ 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 packaged 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. +At the Stage 5 Unit 18 checkpoint it is deliberately instantiated only by +focused tests: the normal navigation rail still marks ML Preparation +unavailable and `Main.qml` has no Preparation loader. Unit 19 owns the remaining +creation, navigation, source-action, command/context, and application-only +integration. This boundary prevents a hidden component from being documented +as an available user workflow before its shell lifecycle is complete. + 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 +822,15 @@ 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, 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. ML Preparation joins this normal +navigation contract only after Unit 19. 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 +1016,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 +1062,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 18; Sweep enabled, Preparation hidden | | 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 +1133,19 @@ 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 18 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, while the Preparation page remains hidden pending Unit 19 shell +integration. Audit projection and presentation, lifecycle hardening, packaged +qualification, complete gates, native acceptance, and completion documentation +remain unfinished. This checkpoint changes private desktop ownership and +presentation only; public scientific and distribution contracts remain +unchanged. + ## Known current limitations - Both public desktop commands launch the single QML presentation. @@ -1013,9 +1156,11 @@ 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 + editor and workflow. The structured Preparation editor is packaged and + directly tested but remains hidden until its Unit 19 shell integration. + Preparation audit presentation remains later Stage 5 work, and 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 +1192,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 +1216,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 18 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,489 | Shell integration now includes global documents and Sweep; Preparation joins in Unit 19 | +| `config_controller.py` | 1,206 | One exact file lifecycle with explicit three-kind draft dispatch | +| `desktop_controller.py` | 2,153 | 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 hidden editor awaiting shell and 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 +1257,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..1aa2bbb 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 is enabled; Preparation is implemented through its hidden editor checkpoint | | 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,61 @@ 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–18 + +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 18 hidden-page 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 directly instantiated + and tested, but ML Preparation remains disabled in normal navigation until + Unit 19 integrates the page, creation flow, source actions, and shell state. + +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 is: + +1. Unit 19 enables and integrates the Preparation workflow surface. +2. Units 20 and 21 project and present typed preparation audit diagnostics. +3. Unit 22 hardens cross-workflow lifecycle and semantic response guards. +4. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. +5. Unit 24 records completion only after automated and manual acceptance. + +The first normal-application Preparation inspection occurs immediately after +Unit 19. Audit presentation is inspected again after Unit 21, lifecycle paths +after Unit 22, and the final installed application 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..96ece64 100644 --- a/ML_PREPARATION_ROADMAP.md +++ b/ML_PREPARATION_ROADMAP.md @@ -50,6 +50,34 @@ 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 18: + +- 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 directly tested but + remain hidden from normal navigation until Unit 19 completes shell, + creation, and source-action integration. + +This is desktop exposure of the implemented preparation contract, not new +preparation science. Typed audit projection and presentation, 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..46ef14b 100644 --- a/README.md +++ b/README.md @@ -162,11 +162,15 @@ Its workflow is: ```text Workspace → Dataset → YAML Preview → Run → Inspect → Visualization + → Model Sweeps → 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. - **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. @@ -177,6 +181,14 @@ Workspace → Dataset → YAML Preview → Run → Inspect → Visualization - **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, but ML Preparation remains hidden from normal navigation until +its remaining shell integration is complete. + 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 +370,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 +452,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: structured Model Sweep is enabled, while the complete +Preparation editor has reached its hidden integration checkpoint and still +requires visible shell integration, audit presentation, lifecycle hardening, +packaged qualification, and acceptance. 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 +497,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 partial Preparation +desktop implementation does not change the published `0.1.0a4` artifacts. ## License diff --git a/docs/agent-guides/SCIENTIFIC_CONTRACTS.md b/docs/agent-guides/SCIENTIFIC_CONTRACTS.md index 294bf34..a1f6d8a 100644 --- a/docs/agent-guides/SCIENTIFIC_CONTRACTS.md +++ b/docs/agent-guides/SCIENTIFIC_CONTRACTS.md @@ -138,8 +138,19 @@ 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, and a directly tested hidden editor. Preparation +does not become a normal visible navigation surface until its remaining shell +integration is complete. 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/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 3a8d986..5e01c89 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -269,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 18" + in desktop + ) def test_readme_documents_published_and_source_release_boundaries() -> None: From c532b4fdc5aa14adf8fbc8b043ac2a3d75259a32 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 17:50:02 +0200 Subject: [PATCH 31/45] feat(app): add guarded preparation creation --- GUI2_PLAN.md | 15 +++++-- src/carnopy/app/config_controller.py | 12 ++++++ src/carnopy/app/desktop_controller.py | 20 ++++++++- tests/test_app_config_controller.py | 28 +++++++++++++ tests/test_app_desktop_controller.py | 60 ++++++++++++++++++++++++++- 5 files changed, 129 insertions(+), 6 deletions(-) diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index 1aa2bbb..d6ebf7b 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -211,7 +211,7 @@ 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–18 +### Implementation checkpoint: Units 1–18 and 19A Stage 5 is in progress on `feat/gui2-stage5`. The implemented checkpoint keeps one globally active configuration document while extending its exact-byte, @@ -246,7 +246,14 @@ Preparation is implemented through the Unit 18 hidden-page checkpoint: saved document plus the explicit source binding; and - the packaged `PreparationPage` and scenario editor are directly instantiated and tested, but ML Preparation remains disabled in normal navigation until - Unit 19 integrates the page, creation flow, source actions, and shell state. + Unit 19B integrates the page, source actions, and shell state. + +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. No public YAML schema, CLI command, Python API, scientific algorithm, manifest, result model, artifact layout, provenance contract, or dependency boundary has @@ -255,14 +262,14 @@ complete Stage 5 gate and native acceptance remain pending. The remaining implementation order is: -1. Unit 19 enables and integrates the Preparation workflow surface. +1. Unit 19B enables and integrates the Preparation workflow surface. 2. Units 20 and 21 project and present typed preparation audit diagnostics. 3. Unit 22 hardens cross-workflow lifecycle and semantic response guards. 4. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. 5. Unit 24 records completion only after automated and manual acceptance. The first normal-application Preparation inspection occurs immediately after -Unit 19. Audit presentation is inspected again after Unit 21, lifecycle paths +Unit 19B. Audit presentation is inspected again after Unit 21, lifecycle paths after Unit 22, and the final installed application after Unit 23; acceptance must not be deferred until the documentation-only completion unit. diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index e7821b4..c8d036c 100644 --- a/src/carnopy/app/config_controller.py +++ b/src/carnopy/app/config_controller.py @@ -446,6 +446,18 @@ def new_sweep(self, discard_confirmed: bool = False) -> bool: 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 diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index 5582be8..38e48de 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -514,6 +514,12 @@ def request_new_sweep(self, discard_confirmed: bool = False) -> bool: return False 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_configuration_lifecycle("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, @@ -1975,9 +1981,21 @@ def _guard_workspace_change(self, *, before_commit: bool) -> bool: return False def _guard_configuration_lifecycle(self, operation: str = "this operation") -> bool: - return self._guard_active_plot_edit(operation) and self._guard_workflow_nested_edit( + 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: diff --git a/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index 3fe8745..1b6fa1c 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -820,6 +820,33 @@ def test_sweep_draft_composes_the_global_saved_document_and_validation_snapshot( 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, @@ -981,6 +1008,7 @@ def test_active_preparation_scenario_blocks_all_global_document_actions( 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())) diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index bdf6b42..d899eeb 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -657,7 +657,7 @@ def test_workflow_facade_routes_sweep_and_preparation_control( assert desktop.shutdown() -def test_sweep_creation_and_generic_open_facade_use_global_configuration_lifecycle( +def test_workflow_creation_and_generic_open_facade_use_global_configuration_lifecycle( tmp_path: Path, application: QCoreApplication, monkeypatch: pytest.MonkeyPatch, @@ -670,6 +670,16 @@ def test_sweep_creation_and_generic_open_facade_use_global_configuration_lifecyc "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", @@ -678,14 +688,58 @@ def test_sweep_creation_and_generic_open_facade_use_global_configuration_lifecyc 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.preparation_workflow_controller, + "get_has_bound_source", + lambda: True, + ) + assert desktop.request_new_preparation(True) + assert controller.get_document_kind() == "preparation" + assert desktop.shutdown() + + def test_sweep_editor_facade_enforces_document_and_worker_edit_guards( tmp_path: Path, application: QCoreApplication, @@ -1395,6 +1449,7 @@ 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", @@ -1412,6 +1467,7 @@ def test_active_plot_edit_blocks_all_composition_lifecycle_paths( 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() @@ -1456,6 +1512,7 @@ def test_active_preparation_edit_blocks_workspace_and_configuration_lifecycle( for name in ( "new_dataset", "new_sweep", + "new_preparation", "import_dataset", "import_configuration", "request_save", @@ -1481,6 +1538,7 @@ def test_active_preparation_edit_blocks_workspace_and_configuration_lifecycle( 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() From 3ba35fd9a8428624a3e8a1cedf9cc66f09735d44 Mon Sep 17 00:00:00 2001 From: gca Date: Thu, 13 Aug 2026 20:28:13 +0200 Subject: [PATCH 32/45] feat(app): enable the preparation workflow surface --- DESKTOP_ARCHITECTURE.md | 60 +++--- GUI2_PLAN.md | 38 ++-- ML_PREPARATION_ROADMAP.md | 8 +- README.md | 19 +- docs/agent-guides/DEVELOPMENT.md | 6 + docs/agent-guides/SCIENTIFIC_CONTRACTS.md | 9 +- src/carnopy/app/qml/Carnopy/Main.qml | 174 ++++++++++++--- .../Carnopy/components/ContextInspector.qml | 27 ++- .../app/qml/Carnopy/components/NavRail.qml | 9 +- .../app/qml/Carnopy/pages/WorkspacePage.qml | 33 +++ src/carnopy/app/qml_runtime.py | 1 + tests/test_app_qml_config.py | 14 ++ tests/test_app_qml_preparation.py | 199 +++++++++++++++++- tests/test_app_qml_shell.py | 4 +- tests/test_app_qml_workspace.py | 5 + tests/test_packaging_metadata.py | 2 +- 16 files changed, 505 insertions(+), 103 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index b948a72..08a1dcd 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -35,10 +35,9 @@ The source tree has one desktop presentation implementation: 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 and tested - as hidden components at the Stage 5 Unit 18 checkpoint, but normal - Preparation navigation and creation remain disabled until Unit 19 completes - shell integration; +- 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 @@ -688,9 +687,8 @@ 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 hidden Unit 18 Preparation page is a view of -these authoritative objects; correctness does not depend on QML component -lifetime. +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` @@ -798,15 +796,15 @@ 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 packaged QML Preparation page presents the bound-source card, all role and +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. -At the Stage 5 Unit 18 checkpoint it is deliberately instantiated only by -focused tests: the normal navigation rail still marks ML Preparation -unavailable and `Main.qml` has no Preparation loader. Unit 19 owns the remaining -creation, navigation, source-action, command/context, and application-only -integration. This boundary prevents a hidden component from being documented -as an available user workflow before its shell lifecycle is complete. +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. The QML Inspect workbench consumes only the typed Qt models owned by `InspectionController`. Workspace discovery is direct-child, symlink-excluding, @@ -822,15 +820,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, Model Sweep, 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 +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. ML Preparation joins this normal -navigation contract only after Unit 19. +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 @@ -1062,7 +1059,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 | In progress through Unit 18; Sweep enabled, Preparation hidden | +| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 19B; both editors enabled, audit 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 | @@ -1133,14 +1130,14 @@ 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 18 on `feat/gui2-stage5`. The former +Stage 5 is implemented through Unit 19B 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, while the Preparation page remains hidden pending Unit 19 shell -integration. Audit projection and presentation, lifecycle hardening, packaged +implemented and enabled through normal shell navigation and guarded Workspace +creation. Audit projection and presentation, lifecycle hardening, packaged qualification, complete gates, native acceptance, and completion documentation remain unfinished. This checkpoint changes private desktop ownership and presentation only; public scientific and distribution contracts remain @@ -1157,9 +1154,10 @@ unchanged. evidence, explicit session rendering, private Run activity, and guarded staging recovery. - The visible QML application now provides the complete structured Model Sweep - editor and workflow. The structured Preparation editor is packaged and - directly tested but remains hidden until its Unit 19 shell integration. - Preparation audit presentation remains later Stage 5 work, and exact + 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. Preparation audit presentation remains later + Stage 5 work, and exact emitted-value 3D presentation remains a later stage. - Native folder dialogs and compositor behavior require human acceptance; headless tests do not automate them. @@ -1216,17 +1214,17 @@ 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 18 checkpoint records the new concentration points separately +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,489 | Shell integration now includes global documents and Sweep; Preparation joins in Unit 19 | -| `config_controller.py` | 1,206 | One exact file lifecycle with explicit three-kind draft dispatch | -| `desktop_controller.py` | 2,153 | Composition-owned guards and the QML command facade for four structured editors | +| `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 hidden editor awaiting shell and native review | +| `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, diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index d6ebf7b..ef50425 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 | In progress | Structured Sweep is enabled; Preparation is implemented through its hidden editor checkpoint | +| 5 | In progress | Structured Sweep and Preparation are enabled; audit presentation and qualification 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 | @@ -229,7 +229,7 @@ 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 18 hidden-page checkpoint: +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; @@ -244,9 +244,9 @@ Preparation is implemented through the Unit 18 hidden-page checkpoint: - 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 directly instantiated - and tested, but ML Preparation remains disabled in normal navigation until - Unit 19B integrates the page, source actions, and shell state. +- 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 @@ -255,23 +255,31 @@ 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. + 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 is: +The remaining implementation order after Unit 19B is: -1. Unit 19B enables and integrates the Preparation workflow surface. -2. Units 20 and 21 project and present typed preparation audit diagnostics. -3. Unit 22 hardens cross-workflow lifecycle and semantic response guards. -4. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. -5. Unit 24 records completion only after automated and manual acceptance. +1. Units 20 and 21 project and present typed preparation audit diagnostics. +2. Unit 22 hardens cross-workflow lifecycle and semantic response guards. +3. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. +4. Unit 24 records completion only after automated and manual acceptance. -The first normal-application Preparation inspection occurs immediately after -Unit 19B. Audit presentation is inspected again after Unit 21, lifecycle paths -after Unit 22, and the final installed application after Unit 23; acceptance -must not be deferred until the documentation-only completion unit. +The first normal-application Preparation inspection is the next checkpoint +after the Unit 19B commit. Audit presentation is inspected again after Unit 21, +lifecycle paths after Unit 22, and the final installed application after Unit +23; acceptance must not be deferred until the documentation-only completion +unit. ## Stage 6: exact scientific 3D scenes diff --git a/ML_PREPARATION_ROADMAP.md b/ML_PREPARATION_ROADMAP.md index 96ece64..3b3a844 100644 --- a/ML_PREPARATION_ROADMAP.md +++ b/ML_PREPARATION_ROADMAP.md @@ -54,7 +54,7 @@ name. 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 18: +implemented through Unit 19B: - Inspect derives typed Preparation eligibility and capability projections from verified dataset-run or model-sweep metadata; @@ -69,9 +69,9 @@ implemented through Unit 18: - 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 directly tested but - remain hidden from normal navigation until Unit 19 completes shell, - creation, and source-action integration. +- 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. This is desktop exposure of the implemented preparation contract, not new preparation science. Typed audit projection and presentation, lifecycle diff --git a/README.md b/README.md index 46ef14b..488e602 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ Its workflow is: ```text Workspace → Dataset → YAML Preview → Run → Inspect → Visualization → Model Sweeps → Plan/Execute → Inspect + → ML Preparation → Plan/Execute → Inspect → Activity and Recovery ``` @@ -171,6 +172,9 @@ Workspace → Dataset → YAML Preview → Run → Inspect → Visualization - **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. @@ -186,8 +190,8 @@ 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, but ML Preparation remains hidden from normal navigation until -its remaining shell integration is complete. +Stage 5 branch and enabled in the normal shell. Preparation audit presentation, +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 @@ -454,12 +458,11 @@ thermophysical engines and data sources The accepted direction is workflow depth now, source breadth next, and advanced 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 is in progress: structured Model Sweep is enabled, while the complete -Preparation editor has reached its hidden integration checkpoint and still -requires visible shell integration, audit presentation, lifecycle hardening, -packaged qualification, and acceptance. 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 are enabled, while Preparation audit presentation, 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 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 a1f6d8a..1cfd4cb 100644 --- a/docs/agent-guides/SCIENTIFIC_CONTRACTS.md +++ b/docs/agent-guides/SCIENTIFIC_CONTRACTS.md @@ -144,10 +144,11 @@ 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, and a directly tested hidden editor. Preparation -does not become a normal visible navigation surface until its remaining shell -integration is complete. The source binding is private execution context and -never adds a path to Preparation YAML. QML remains a typed presentation of the +drafts, planning, execution, and an enabled structured editor. 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. diff --git a/src/carnopy/app/qml/Carnopy/Main.qml b/src/carnopy/app/qml/Carnopy/Main.qml index 9db29bd..8a10f08 100644 --- a/src/carnopy/app/qml/Carnopy/Main.qml +++ b/src/carnopy/app/qml/Carnopy/Main.qml @@ -47,6 +47,7 @@ ApplicationWindow { signal datasetValidateRequested signal datasetReloadRequested(bool discardConfirmed) signal sweepNewRequested(bool discardConfirmed) + signal preparationNewRequested(bool discardConfirmed) signal runCancelRequested signal runForceStopRequested signal runGenerateRequested @@ -171,6 +172,8 @@ ApplicationWindow { 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") @@ -262,6 +265,24 @@ ApplicationWindow { 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"); @@ -296,6 +317,40 @@ ApplicationWindow { 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 () { @@ -500,6 +555,8 @@ ApplicationWindow { 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 @@ -558,6 +615,8 @@ ApplicationWindow { 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 @@ -579,36 +638,40 @@ ApplicationWindow { return qsTr("No workspace"); } statusTone: root.currentPage === "sweeps" ? root.sweepStatusTone() : ( - 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" : - (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")))) + root.currentPage + === "preparation" + ? root.preparationStatusTone() : + (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" : + (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 } @@ -719,6 +782,16 @@ ApplicationWindow { 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 @@ -882,6 +955,9 @@ 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 : @@ -924,6 +1000,8 @@ ApplicationWindow { 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 @@ -967,6 +1045,9 @@ 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 : "" @@ -1007,6 +1088,7 @@ ApplicationWindow { onOpenWorkspaceRequested: path => root.workspaceOpenRequested(path) onImportConfigurationRequested: path => root.requestConfigurationImport(path) onNewDatasetRequested: mode => root.requestDatasetNew(mode) + onNewPreparationRequested: root.requestPreparationNew() onNewSweepRequested: root.requestSweepNew() } } @@ -1075,6 +1157,25 @@ ApplicationWindow { } } + 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" + onInspectSourceRequested: root.routeTo("inspect") + onWorkspaceRequested: root.routeTo("workspace") + preparationDraft: root.configController.preparationDraft + workflowController: root.preparationWorkflowController + } + } + Component { id: visualizationPage @@ -1226,7 +1327,8 @@ ApplicationWindow { } function onAttentionRequested(section, field, row) { - if (section !== "dataset" && section !== "sweep" && section !== "visualization") + if (section !== "dataset" && section !== "sweep" && section !== "preparation" && section + !== "visualization") return; root.routeTo(section === "sweep" ? "sweeps" : section); root.pendingAttentionField = field; @@ -1244,7 +1346,10 @@ ApplicationWindow { function onConfigurationDocumentOpened(documentKind) { root.routeTo(documentKind === "dataset" ? "dataset" : (documentKind === "model_sweep" - ? "sweeps" : "yaml")); + ? "sweeps" : (documentKind + === "preparation" + ? "preparation" : + "yaml"))); } function onNavigationRequested(pageKey, detail) { @@ -1278,7 +1383,8 @@ ApplicationWindow { const workspaceAvailable = root.desktopController.workspaceAvailable; if ((root.currentPage === "dataset" || root.currentPage === "run" || root.currentPage === "inspect" || root.currentPage === "visualization" || root.currentPage - === "activity" || root.currentPage === "sweeps") && !workspaceAvailable) + === "activity" || root.currentPage === "sweeps" || root.currentPage + === "preparation") && !workspaceAvailable) root.routeTo("workspace"); if (root.currentPage === "yaml" && (root.configController === null || !root.configController.hasDocument)) @@ -1353,6 +1459,8 @@ ApplicationWindow { 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 = ""; diff --git a/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml b/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml index ae8b82e..f9c6858 100644 --- a/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml +++ b/src/carnopy/app/qml/Carnopy/components/ContextInspector.qml @@ -21,6 +21,8 @@ Control { 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" @@ -103,7 +105,7 @@ Control { flickableDirection: Flickable.VerticalFlick pixelAligned: true visible: root.pageKey !== "run" && root.pageKey !== "inspect" && root.pageKey - !== "activity" && root.pageKey !== "sweeps" + !== "activity" && root.pageKey !== "sweeps" && root.pageKey !== "preparation" ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded @@ -345,5 +347,28 @@ Control { 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 b835a31..4ea20aa 100644 --- a/src/carnopy/app/qml/Carnopy/components/NavRail.qml +++ b/src/carnopy/app/qml/Carnopy/components/NavRail.qml @@ -16,6 +16,7 @@ Control { 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 @@ -111,8 +112,8 @@ Control { 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" @@ -211,7 +212,9 @@ Control { pageKey !== "activity" || root.activityAvailable) && ( pageKey !== "sweeps" - || root.sweepsAvailable) + || root.sweepsAvailable) && ( + pageKey !== "preparation" + || root.preparationAvailable) Accessible.description: effectivelyAvailable ? "" : unavailableReason Accessible.name: title diff --git a/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml b/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml index cede95c..4f707a6 100644 --- a/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/WorkspacePage.qml @@ -38,6 +38,7 @@ Item { signal openWorkspaceRequested(string path) signal importConfigurationRequested(string path) signal newDatasetRequested(string mode) + signal newPreparationRequested signal newSweepRequested property bool importSelectionAccepted: false property string importSelectionPath: "" @@ -379,6 +380,38 @@ Item { } } + 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( diff --git a/src/carnopy/app/qml_runtime.py b/src/carnopy/app/qml_runtime.py index 01b8027..31407da 100644 --- a/src/carnopy/app/qml_runtime.py +++ b/src/carnopy/app/qml_runtime.py @@ -489,6 +489,7 @@ def _connect_qml_facade(self, root: QObject) -> None: ("workspaceCancelRequested", self.controller.request_cancel_workspace_operation), ("datasetNewRequested", self.controller.request_new_dataset), ("sweepNewRequested", self.controller.request_new_sweep), + ("preparationNewRequested", self.controller.request_new_preparation), ( "configurationImportRequested", self.controller.request_import_configuration, diff --git a/tests/test_app_qml_config.py b/tests/test_app_qml_config.py index 89797d9..80f9a61 100644 --- a/tests/test_app_qml_config.py +++ b/tests/test_app_qml_config.py @@ -62,6 +62,20 @@ def test_global_shell_routes_sweep_documents_to_the_structured_editor( 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() diff --git a/tests/test_app_qml_preparation.py b/tests/test_app_qml_preparation.py index 67a9530..3e39b9e 100644 --- a/tests/test_app_qml_preparation.py +++ b/tests/test_app_qml_preparation.py @@ -11,7 +11,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") pytest.importorskip("PySide6") -from PySide6.QtCore import QCoreApplication, QEventLoop, QSettings, QTimer +from PySide6.QtCore import QCoreApplication, QEventLoop, QMetaObject, QObject, QSettings, QTimer from PySide6.QtQml import QQmlComponent from PySide6.QtQuick import QQuickItem, QQuickWindow from PySide6.QtWidgets import QApplication @@ -162,6 +162,159 @@ def _visual_names(root: QQuickItem) -> set[str]: 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 QMetaObject.invokeMethod(bind_button, "click") + _process_events() + assert desktop.preparation_workflow_controller.get_has_bound_source() + + 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_scenario_editor_binds_the_complete_temporary_surface( runtime: QmlApplicationRuntime, scenario_editor: QQuickItem, @@ -273,7 +426,7 @@ def test_preparation_scenario_qml_resource_and_controller_boundary_are_explicit( assert "yaml" not in source.casefold() -def test_hidden_preparation_page_binds_the_complete_authoritative_editor( +def test_preparation_page_binds_the_complete_authoritative_editor( runtime: QmlApplicationRuntime, preparation_page: QQuickItem, ) -> None: @@ -332,6 +485,45 @@ def test_hidden_preparation_page_binds_the_complete_authoritative_editor( 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, @@ -405,3 +597,6 @@ def test_preparation_page_qml_resource_and_controller_boundary_are_explicit() -> assert ".commitScenario(" 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_shell.py b/tests/test_app_qml_shell.py index 7817bb0..77c0530 100644 --- a/tests/test_app_qml_shell.py +++ b/tests/test_app_qml_shell.py @@ -147,7 +147,7 @@ def test_shell_uses_exact_navigation_order_and_enables_only_integrated_workflows ) assert tuple( model.data(model.index(row, 0), available_role) for row in range(model.rowCount()) - ) == (True, True, True, True, True, True, True, True, 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" ) @@ -167,6 +167,8 @@ def test_shell_uses_exact_navigation_order_and_enables_only_integrated_workflows 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_workspace.py b/tests/test_app_qml_workspace.py index 3be0528..195fdba 100644 --- a/tests/test_app_qml_workspace.py +++ b/tests/test_app_qml_workspace.py @@ -234,6 +234,11 @@ def test_qml_facade_creates_workspace_from_parent_and_refreshes_bound_state( 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_packaging_metadata.py b/tests/test_packaging_metadata.py index 5e01c89..22ff674 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -272,7 +272,7 @@ def test_public_roadmap_separates_current_contracts_from_future_direction() -> N 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 | In progress through Unit 18" + "| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 19B" in desktop ) From 8574760cdd281bc358fa73ae0f29579465c7679e Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 03:02:14 +0200 Subject: [PATCH 33/45] fix(sweep): record finalized comparison plot paths --- src/carnopy/sweeps/pipeline.py | 1 + src/carnopy/sweeps/plots.py | 15 +++++++++++---- tests/test_model_sweeps.py | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) 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_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: From 52b0cc46365e9f665d2aed5daef166ef96a81934 Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 03:30:31 +0200 Subject: [PATCH 34/45] feat(app): expose verified preparation quality flags --- DESKTOP_ARCHITECTURE.md | 8 +++++-- GUI2_PLAN.md | 16 +++++++++++--- ML_PREPARATION_ROADMAP.md | 14 +++++++----- README.md | 2 +- src/carnopy/app/source_inspection.py | 12 ++++++++++ tests/test_app_inspection.py | 33 +++++++++++++++++++++++++++- 6 files changed, 73 insertions(+), 12 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index 08a1dcd..a9f79db 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -49,8 +49,9 @@ The source tree has one desktop presentation implementation: 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 is its view; - 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; @@ -302,6 +303,9 @@ workflow. It owns: - 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; - selected table identity, 500-row worker blocks, and 100-row local pages; and - the copied inspected plot context consumed by `SessionPlotController`. diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index ef50425..6a1f504 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -211,7 +211,7 @@ 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–18 and 19A +### Implementation checkpoint: Units 1–19B and 20A Stage 5 is in progress on `feat/gui2-stage5`. The implemented checkpoint keeps one globally active configuration document while extending its exact-byte, @@ -263,14 +263,24 @@ 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. Typed audit projections and their QML surface +remain in Units 20B, 20C, and 21. + 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 19B is: +The remaining implementation order after Unit 20A is: -1. Units 20 and 21 project and present typed preparation audit diagnostics. +1. Units 20B, 20C, and 21 project and present typed preparation audit + diagnostics. 2. Unit 22 hardens cross-workflow lifecycle and semantic response guards. 3. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. 4. Unit 24 records completion only after automated and manual acceptance. diff --git a/ML_PREPARATION_ROADMAP.md b/ML_PREPARATION_ROADMAP.md index 3b3a844..a88daf8 100644 --- a/ML_PREPARATION_ROADMAP.md +++ b/ML_PREPARATION_ROADMAP.md @@ -54,7 +54,7 @@ name. 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 19B: +implemented through Unit 20A: - Inspect derives typed Preparation eligibility and capability projections from verified dataset-run or model-sweep metadata; @@ -71,12 +71,16 @@ implemented through Unit 19B: 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. + 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. This is desktop exposure of the implemented preparation contract, not new -preparation science. Typed audit projection and presentation, lifecycle -hardening, packaged qualification, and native acceptance remain later Stage 5 -work. +preparation science. The remaining typed audit projection and presentation, +lifecycle hardening, packaged qualification, and native acceptance remain later +Stage 5 work. ## Reviewed future direction — Optional PyTorch dataset export diff --git a/README.md b/README.md index 488e602..9c7c70a 100644 --- a/README.md +++ b/README.md @@ -501,7 +501,7 @@ gates, native acceptance, [PyPI publication](https://pypi.org/project/carnopy/0. 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 in progress on -its dedicated feature branch; its structured Sweep and partial Preparation +its dedicated feature branch; its structured Sweep and Preparation desktop implementation does not change the published `0.1.0a4` artifacts. ## License diff --git a/src/carnopy/app/source_inspection.py b/src/carnopy/app/source_inspection.py index 29f43a2..a4d2821 100644 --- a/src/carnopy/app/source_inspection.py +++ b/src/carnopy/app/source_inspection.py @@ -347,6 +347,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): diff --git a/tests/test_app_inspection.py b/tests/test_app_inspection.py index c5c844c..e3bbda0 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 @@ -363,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" @@ -375,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]}), } @@ -394,6 +402,7 @@ 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"}], @@ -429,11 +438,33 @@ 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"},) + 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) + + 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: root = tmp_path / "sweep" From c9c446aae2c82612ec26aa9974f858c9f1d062aa Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 03:56:44 +0200 Subject: [PATCH 35/45] feat(app): project preparation audit diagnostics --- DESKTOP_ARCHITECTURE.md | 20 +- GUI2_PLAN.md | 20 +- ML_PREPARATION_ROADMAP.md | 15 +- src/carnopy/app/preparation_audit.py | 1116 ++++++++++++++++++++++++++ tests/test_app_preparation_audit.py | 484 +++++++++++ 5 files changed, 1637 insertions(+), 18 deletions(-) create mode 100644 src/carnopy/app/preparation_audit.py create mode 100644 tests/test_app_preparation_audit.py diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index a9f79db..0a29ac6 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -1063,7 +1063,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 | In progress through Unit 19B; both editors enabled, audit pending | +| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 20B; both editors enabled, typed audit projection awaits integration | | 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 | @@ -1134,18 +1134,24 @@ 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 19B on `feat/gui2-stage5`. The former +Stage 5 is implemented through Unit 20B 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. Audit projection and presentation, lifecycle hardening, packaged -qualification, complete gates, native acceptance, and completion documentation -remain unfinished. This checkpoint changes private desktop ownership and -presentation only; public scientific and distribution contracts remain -unchanged. +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 reserves a versioned +private scenario-detail input for worker-verified leakage evidence rather than +inferring it from successful finalization. Inspection-controller integration +and audit presentation, lifecycle hardening, packaged qualification, complete +gates, native acceptance, and completion documentation remain unfinished. This +checkpoint changes private desktop ownership and presentation infrastructure +only; public scientific and distribution contracts remain unchanged. ## Known current limitations diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index 6a1f504..76ced77 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -211,7 +211,7 @@ 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–19B and 20A +### Implementation checkpoint: Units 1–20B Stage 5 is in progress on `feat/gui2-stage5`. The implemented checkpoint keeps one globally active configuration document while extending its exact-byte, @@ -269,18 +269,26 @@ 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. Typed audit projections and their QML surface -remain in Units 20B, 20C, and 21. +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. 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 20A is: +The remaining implementation order after Unit 20B is: -1. Units 20B, 20C, and 21 project and present typed preparation audit - diagnostics. +1. Units 20C and 21 integrate and present typed preparation audit diagnostics. 2. Unit 22 hardens cross-workflow lifecycle and semantic response guards. 3. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. 4. Unit 24 records completion only after automated and manual acceptance. diff --git a/ML_PREPARATION_ROADMAP.md b/ML_PREPARATION_ROADMAP.md index a88daf8..ab1d447 100644 --- a/ML_PREPARATION_ROADMAP.md +++ b/ML_PREPARATION_ROADMAP.md @@ -54,7 +54,7 @@ name. 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 20A: +implemented through Unit 20B: - Inspect derives typed Preparation eligibility and capability projections from verified dataset-run or model-sweep metadata; @@ -75,12 +75,17 @@ implemented through Unit 20A: - 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. + 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. This is desktop exposure of the implemented preparation contract, not new -preparation science. The remaining typed audit projection and presentation, -lifecycle hardening, packaged qualification, and native acceptance remain later -Stage 5 work. +preparation science. Worker/controller integration of the audit projection and +its presentation, lifecycle hardening, packaged qualification, and native +acceptance remain later Stage 5 work. ## Reviewed future direction — Optional PyTorch dataset export diff --git a/src/carnopy/app/preparation_audit.py b/src/carnopy/app/preparation_audit.py new file mode 100644 index 0000000..99f8ed6 --- /dev/null +++ b/src/carnopy/app/preparation_audit.py @@ -0,0 +1,1116 @@ +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, ...] + 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) + scenarios, partitions, leakage_audits, scenario_status = _scenario_rows( + summary.get("scenarios"), + scenario_evidence, + evidence_available=scenario_evidence_available, + ) + duplicate_rows, duplicate_status = _duplicate_state_rows( + quality_summary.get("duplicate_state_candidates") + ) + grid_groups, grid_spacing, grid_phases, grid_status = _grid_rows( + quality_summary.get("structured_grid") + ) + ( + matrix_checks, + matrix_feature_flags, + singular_values, + correlated_pairs, + target_correlations, + matrix_status, + ) = _matrix_rows(quality_summary.get("matrix_diagnostics")) + baseline_checks, baseline_metrics, baseline_failures, baseline_status = _baseline_rows( + quality_summary.get("baseline_diagnostics") + ) + + 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, + 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/tests/test_app_preparation_audit.py b/tests/test_app_preparation_audit.py new file mode 100644 index 0000000..e3c4757 --- /dev/null +++ b/tests/test_app_preparation_audit.py @@ -0,0 +1,484 @@ +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.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.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 From e7b7c7600c2d10e51954c7dabf18fa5ab9319985 Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 04:46:58 +0200 Subject: [PATCH 36/45] feat(app): integrate preparation audit diagnostics --- DESKTOP_ARCHITECTURE.md | 38 +++- GUI2_PLAN.md | 18 +- ML_PREPARATION_ROADMAP.md | 14 +- src/carnopy/app/inspection_controller.py | 55 ++++-- src/carnopy/app/preparation_audit.py | 31 ++- src/carnopy/app/preparation_audit_models.py | 206 ++++++++++++++++++++ src/carnopy/app/source_inspection.py | 138 +++++++++++++ tests/test_app_inspection.py | 57 ++++++ tests/test_app_inspection_controller.py | 118 +++++++++++ tests/test_app_preparation_audit.py | 8 + tests/test_app_preparation_audit_models.py | 103 ++++++++++ 11 files changed, 749 insertions(+), 37 deletions(-) create mode 100644 src/carnopy/app/preparation_audit_models.py create mode 100644 tests/test_app_preparation_audit_models.py diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index 0a29ac6..2770ea0 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -306,6 +306,10 @@ workflow. It owns: - 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`. @@ -330,6 +334,16 @@ 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. + 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 @@ -1063,7 +1077,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 | In progress through Unit 20B; both editors enabled, typed audit projection awaits integration | +| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 20C; both editors enabled, typed audit presentation 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 | @@ -1134,7 +1148,7 @@ 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 20B on `feat/gui2-stage5`. The former +Stage 5 is implemented through Unit 20C 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 @@ -1145,13 +1159,19 @@ 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 reserves a versioned -private scenario-detail input for worker-verified leakage evidence rather than -inferring it from successful finalization. Inspection-controller integration -and audit presentation, lifecycle hardening, packaged qualification, complete -gates, native acceptance, and completion documentation remain unfinished. This -checkpoint changes private desktop ownership and presentation infrastructure -only; public scientific and distribution contracts remain unchanged. +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. Audit presentation, lifecycle hardening, packaged qualification, +complete gates, native acceptance, and completion documentation remain +unfinished. This checkpoint changes private desktop ownership and presentation +infrastructure only; public scientific and distribution contracts remain +unchanged. ## Known current limitations diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index 76ced77..188478e 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -211,7 +211,7 @@ 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–20B +### Implementation checkpoint: Units 1–20C Stage 5 is in progress on `feat/gui2-stage5`. The implemented checkpoint keeps one globally active configuration document while extending its exact-byte, @@ -281,14 +281,26 @@ 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. +No audit QML is added before Unit 21. + 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 20B is: +The remaining implementation order after Unit 20C is: -1. Units 20C and 21 integrate and present typed preparation audit diagnostics. +1. Unit 21 presents the integrated typed preparation audit diagnostics. 2. Unit 22 hardens cross-workflow lifecycle and semantic response guards. 3. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. 4. Unit 24 records completion only after automated and manual acceptance. diff --git a/ML_PREPARATION_ROADMAP.md b/ML_PREPARATION_ROADMAP.md index ab1d447..1dca51e 100644 --- a/ML_PREPARATION_ROADMAP.md +++ b/ML_PREPARATION_ROADMAP.md @@ -54,7 +54,7 @@ name. 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 20B: +implemented through Unit 20C: - Inspect derives typed Preparation eligibility and capability projections from verified dataset-run or model-sweep metadata; @@ -80,12 +80,16 @@ implemented through Unit 20B: 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. + 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. This is desktop exposure of the implemented preparation contract, not new -preparation science. Worker/controller integration of the audit projection and -its presentation, lifecycle hardening, packaged qualification, and native -acceptance remain later Stage 5 work. +preparation science. Audit presentation, lifecycle hardening, packaged +qualification, and native acceptance remain later Stage 5 work. ## Reviewed future direction — Optional PyTorch dataset export diff --git a/src/carnopy/app/inspection_controller.py b/src/carnopy/app/inspection_controller.py index 2b19a77..22afa0f 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, @@ -98,6 +100,7 @@ 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", @@ -529,6 +532,11 @@ 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) @@ -874,6 +882,22 @@ def _accept_inspection_payload(self, payload: dict[str, Any]) -> None: {"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 @@ -893,7 +917,7 @@ 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) @@ -954,6 +978,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() @@ -1013,8 +1039,14 @@ def _reset_projection(self) -> None: 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"]) @@ -1033,7 +1065,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")) @@ -1150,7 +1183,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, @@ -1186,15 +1223,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, diff --git a/src/carnopy/app/preparation_audit.py b/src/carnopy/app/preparation_audit.py index 99f8ed6..c141007 100644 --- a/src/carnopy/app/preparation_audit.py +++ b/src/carnopy/app/preparation_audit.py @@ -197,6 +197,12 @@ class PreparationAuditProjection: 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], ...] @@ -228,17 +234,17 @@ def from_worker_payload( 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( - summary.get("scenarios"), + raw_scenarios, scenario_evidence, evidence_available=scenario_evidence_available, ) - duplicate_rows, duplicate_status = _duplicate_state_rows( - quality_summary.get("duplicate_state_candidates") - ) - grid_groups, grid_spacing, grid_phases, grid_status = _grid_rows( - quality_summary.get("structured_grid") - ) + 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, @@ -246,9 +252,10 @@ def from_worker_payload( correlated_pairs, target_correlations, matrix_status, - ) = _matrix_rows(quality_summary.get("matrix_diagnostics")) + ) = _matrix_rows(raw_matrix) + raw_baseline = quality_summary.get("baseline_diagnostics") baseline_checks, baseline_metrics, baseline_failures, baseline_status = _baseline_rows( - quality_summary.get("baseline_diagnostics") + raw_baseline ) eligible, eligible_available = _optional_nonnegative_integer( @@ -324,6 +331,12 @@ def from_worker_payload( 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, 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/source_inspection.py b/src/carnopy/app/source_inspection.py index a4d2821..840d6db 100644 --- a/src/carnopy/app/source_inspection.py +++ b/src/carnopy/app/source_inspection.py @@ -77,6 +77,7 @@ class ResolvedInspection: 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 { @@ -91,6 +92,7 @@ def public_payload(self) -> dict[str, Any]: "preparation_ineligible_reason": self.preparation_ineligible_reason, "preparation_source_descriptor": self.preparation_source_descriptor, "preparation_profile": self.preparation_profile, + "preparation_audit": self.preparation_audit, } @@ -103,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) @@ -121,6 +131,11 @@ 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() + preparation_audit = ( + _preparation_audit_payload(requested, inspection.manifest) + if isinstance(inspection, PreparationInspection) + else None + ) eligible, ineligible_reason, preparation_descriptor, preparation_profile = ( _preparation_eligibility( requested, @@ -141,6 +156,7 @@ def inspect_for_app(source: str | Path) -> ResolvedInspection: preparation_ineligible_reason=ineligible_reason, preparation_source_descriptor=preparation_descriptor, preparation_profile=preparation_profile, + preparation_audit=preparation_audit, ) @@ -194,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), @@ -317,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], diff --git a/tests/test_app_inspection.py b/tests/test_app_inspection.py index e3bbda0..8d8dd61 100644 --- a/tests/test_app_inspection.py +++ b/tests/test_app_inspection.py @@ -390,7 +390,25 @@ def test_preparation_descriptors_cover_quality_flags_and_scenario_tables(tmp_pat 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, @@ -408,12 +426,15 @@ def test_preparation_descriptors_cover_quality_flags_and_scenario_tables(tmp_pat "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", @@ -425,6 +446,15 @@ def test_preparation_descriptors_cover_quality_flags_and_scenario_tables(tmp_pat "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, + ) + }, } ], }, @@ -443,6 +473,20 @@ def test_preparation_descriptors_cover_quality_flags_and_scenario_tables(tmp_pat "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" @@ -456,6 +500,19 @@ def test_preparation_descriptors_cover_quality_flags_and_scenario_tables(tmp_pat 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 diff --git a/tests/test_app_inspection_controller.py b/tests/test_app_inspection_controller.py index e973262..8cd2538 100644 --- a/tests/test_app_inspection_controller.py +++ b/tests/test_app_inspection_controller.py @@ -142,6 +142,52 @@ def preparation_profile( } +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): @@ -548,6 +594,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, diff --git a/tests/test_app_preparation_audit.py b/tests/test_app_preparation_audit.py index e3c4757..a2b4660 100644 --- a/tests/test_app_preparation_audit.py +++ b/tests/test_app_preparation_audit.py @@ -236,6 +236,12 @@ def test_projection_flattens_finalized_audit_evidence_deterministically() -> Non 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, @@ -313,6 +319,8 @@ def test_projection_represents_legacy_quality_absence_without_fabricating_rows() 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 == () 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() From 6140c538d3f5524d0848a1b76dcf721b26927589 Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 05:16:47 +0200 Subject: [PATCH 37/45] feat(app): add preparation audit view --- DESKTOP_ARCHITECTURE.md | 33 +- GUI2_PLAN.md | 22 +- ML_PREPARATION_ROADMAP.md | 12 +- README.md | 8 +- scripts/check_distribution.py | 1 + .../components/PreparationAuditView.qml | 831 ++++++++++++++++++ src/carnopy/app/qml/Carnopy/qmldir | 1 + src/carnopy/app/qml_resources.py | 1 + tests/test_app_qml_preparation_audit.py | 517 +++++++++++ tests/test_app_qml_runtime.py | 2 +- 10 files changed, 1404 insertions(+), 24 deletions(-) create mode 100644 src/carnopy/app/qml/Carnopy/components/PreparationAuditView.qml create mode 100644 tests/test_app_qml_preparation_audit.py diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index 2770ea0..03e6d7f 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -344,6 +344,15 @@ 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; normal `InspectPage` integration remains +the separate Unit 21B boundary. + 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 @@ -1077,7 +1086,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 | In progress through Unit 20C; both editors enabled, typed audit presentation pending | +| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 21A; both editors enabled, reusable typed audit view packaged, Inspect integration 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 | @@ -1148,7 +1157,7 @@ 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 20C on `feat/gui2-stage5`. The former +Stage 5 is implemented through Unit 21A 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 @@ -1167,11 +1176,15 @@ 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. Audit presentation, lifecycle hardening, packaged qualification, -complete gates, native acceptance, and completion documentation remain -unfinished. This checkpoint changes private desktop ownership and presentation -infrastructure only; public scientific and distribution contracts remain -unchanged. +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 but is not inserted into normal Inspect navigation until +Unit 21B. Integrated audit presentation, lifecycle hardening, packaged +qualification, complete gates, native acceptance, and completion documentation +remain unfinished. This checkpoint changes private desktop ownership and +presentation infrastructure only; public scientific and distribution contracts +remain unchanged. ## Known current limitations @@ -1186,9 +1199,9 @@ unchanged. - 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. Preparation audit presentation remains later - Stage 5 work, and exact - emitted-value 3D presentation remains a later stage. + opens without a source path. The typed Preparation audit component is packaged + and directly tested; its normal Inspect integration remains Stage 5 work, and + 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 diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index 188478e..e53ea73 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 | In progress | Structured Sweep and Preparation are enabled; audit presentation and qualification remain | +| 5 | In progress | Structured Sweep and Preparation are enabled; the audit view awaits Inspect integration, lifecycle hardening, and qualification | | 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 | @@ -211,7 +211,7 @@ 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–20C +### Implementation checkpoint: Units 1–21A Stage 5 is in progress on `feat/gui2-stage5`. The implemented checkpoint keeps one globally active configuration document while extending its exact-byte, @@ -291,22 +291,32 @@ 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. -No audit QML is added before Unit 21. + +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. Unit 21B remains responsible for placing this component in the Inspect +workflow and completing the integrated interaction tests. 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 20C is: +The remaining implementation order after Unit 21A is: -1. Unit 21 presents the integrated typed preparation audit diagnostics. +1. Unit 21B integrates the typed Preparation audit component into Inspect. 2. Unit 22 hardens cross-workflow lifecycle and semantic response guards. 3. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. 4. Unit 24 records completion only after automated and manual acceptance. The first normal-application Preparation inspection is the next checkpoint -after the Unit 19B commit. Audit presentation is inspected again after Unit 21, +after the Unit 19B commit. Audit presentation is inspected again after Unit 21B, lifecycle paths after Unit 22, and the final installed application after Unit 23; acceptance must not be deferred until the documentation-only completion unit. diff --git a/ML_PREPARATION_ROADMAP.md b/ML_PREPARATION_ROADMAP.md index 1dca51e..04f7407 100644 --- a/ML_PREPARATION_ROADMAP.md +++ b/ML_PREPARATION_ROADMAP.md @@ -54,7 +54,7 @@ name. 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 20C: +implemented through Unit 21A: - Inspect derives typed Preparation eligibility and capability projections from verified dataset-run or model-sweep metadata; @@ -85,11 +85,15 @@ implemented through Unit 20C: 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. + 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. Its normal Inspect + integration remains the separate Unit 21B boundary. This is desktop exposure of the implemented preparation contract, not new -preparation science. Audit presentation, lifecycle hardening, packaged -qualification, and native acceptance remain later Stage 5 work. +preparation science. Integrated audit presentation, lifecycle hardening, +packaged qualification, and native acceptance remain later Stage 5 work. ## Reviewed future direction — Optional PyTorch dataset export diff --git a/README.md b/README.md index 9c7c70a..0820511 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,8 @@ 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. Preparation audit presentation, +Stage 5 branch and enabled in the normal shell. A typed Preparation audit view +is packaged and directly tested, while its normal Inspect integration, lifecycle hardening, packaged qualification, and acceptance remain unfinished. Scientific generation, inspection, and Matplotlib rendering run in short-lived @@ -459,8 +460,9 @@ The accepted direction is workflow depth now, source breadth next, and advanced 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 is in progress: the complete structured Model Sweep and Preparation -editors are enabled, while Preparation audit presentation, lifecycle hardening, -packaged qualification, and acceptance remain. After that milestone, Carnopy +editors are enabled and the typed Preparation audit view is packaged for its +pending Inspect integration, 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. diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py index 2f9ec11..42748ee 100644 --- a/scripts/check_distribution.py +++ b/scripts/check_distribution.py @@ -49,6 +49,7 @@ "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", 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/qmldir b/src/carnopy/app/qml/Carnopy/qmldir index 98cd0a1..92c2d8e 100644 --- a/src/carnopy/app/qml/Carnopy/qmldir +++ b/src/carnopy/app/qml/Carnopy/qmldir @@ -9,6 +9,7 @@ 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 diff --git a/src/carnopy/app/qml_resources.py b/src/carnopy/app/qml_resources.py index 6a14a3f..7ee4be7 100644 --- a/src/carnopy/app/qml_resources.py +++ b/src/carnopy/app/qml_resources.py @@ -35,6 +35,7 @@ "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", 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_runtime.py b/tests/test_app_qml_runtime.py index 1978df1..b8e41ce 100644 --- a/tests/test_app_qml_runtime.py +++ b/tests/test_app_qml_runtime.py @@ -642,4 +642,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 45 file(s).\n" + assert completed.stdout == "QML checks passed for 46 file(s).\n" From 30f5d45d2f59f4762916e181d5809b797bcd5c23 Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 05:41:13 +0200 Subject: [PATCH 38/45] feat(app): integrate preparation audits into Inspect --- DESKTOP_ARCHITECTURE.md | 35 ++-- GUI2_PLAN.md | 36 +++-- ML_PREPARATION_ROADMAP.md | 13 +- README.md | 16 +- docs/agent-guides/SCIENTIFIC_CONTRACTS.md | 11 +- .../app/qml/Carnopy/pages/InspectPage.qml | 76 ++++++++- tests/test_app_qml_inspection.py | 149 ++++++++++++++++++ 7 files changed, 288 insertions(+), 48 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index 03e6d7f..4d66067 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -51,7 +51,8 @@ The source tree has one desktop presentation implementation: - source discovery, worker inspection, typed source summaries, logical-array metadata, integrity-verified Preparation quality flags, table selection, and bounded preview state are owned by one `InspectionController`; the public QML - Inspect workbench is its view; + 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; @@ -349,9 +350,11 @@ 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; normal `InspectPage` integration remains -the separate Unit 21B boundary. +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 @@ -1086,7 +1089,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 | In progress through Unit 21A; both editors enabled, reusable typed audit view packaged, Inspect integration pending | +| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 21B; both editors and typed Preparation audit inspection enabled, lifecycle hardening 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 | @@ -1157,7 +1160,7 @@ 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 21A on `feat/gui2-stage5`. The former +Stage 5 is implemented through Unit 21B 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 @@ -1179,12 +1182,14 @@ 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 but is not inserted into normal Inspect navigation until -Unit 21B. Integrated audit presentation, lifecycle hardening, packaged -qualification, complete gates, native acceptance, and completion documentation -remain unfinished. This checkpoint changes private desktop ownership and -presentation infrastructure only; public scientific and distribution contracts -remain unchanged. +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. Lifecycle +hardening, packaged qualification, complete gates, native acceptance, and +completion documentation remain unfinished. This checkpoint changes private +desktop ownership and presentation infrastructure only; public scientific and +distribution contracts remain unchanged. ## Known current limitations @@ -1199,9 +1204,9 @@ remain unchanged. - 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. The typed Preparation audit component is packaged - and directly tested; its normal Inspect integration remains Stage 5 work, and - exact emitted-value 3D presentation remains a later stage. + 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 diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index e53ea73..6239103 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 | In progress | Structured Sweep and Preparation are enabled; the audit view awaits Inspect integration, lifecycle hardening, and qualification | +| 5 | In progress | Structured Sweep, Preparation, and typed audit inspection are enabled; lifecycle hardening and qualification 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 | @@ -211,7 +211,7 @@ 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–21A +### Implementation checkpoint: Units 1–21B Stage 5 is in progress on `feat/gui2-stage5`. The implemented checkpoint keeps one globally active configuration document while extending its exact-byte, @@ -300,26 +300,34 @@ 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. Unit 21B remains responsible for placing this component in the Inspect -workflow and completing the integrated interaction tests. +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. 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 21A is: +The remaining implementation order after Unit 21B is: -1. Unit 21B integrates the typed Preparation audit component into Inspect. -2. Unit 22 hardens cross-workflow lifecycle and semantic response guards. -3. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. -4. Unit 24 records completion only after automated and manual acceptance. +1. Unit 22 hardens cross-workflow lifecycle and semantic response guards. +2. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. +3. Unit 24 records completion only after automated and manual acceptance. -The first normal-application Preparation inspection is the next checkpoint -after the Unit 19B commit. Audit presentation is inspected again after Unit 21B, -lifecycle paths after Unit 22, and the final installed application after Unit -23; acceptance must not be deferred until the documentation-only completion -unit. +The Unit 21B commit is the audit-presentation checkpoint for another +normal-application inspection before lifecycle work continues. Lifecycle paths +are inspected after Unit 22, and the final installed application after Unit 23; +acceptance must not be deferred until the documentation-only completion unit. ## Stage 6: exact scientific 3D scenes diff --git a/ML_PREPARATION_ROADMAP.md b/ML_PREPARATION_ROADMAP.md index 04f7407..ac6ce26 100644 --- a/ML_PREPARATION_ROADMAP.md +++ b/ML_PREPARATION_ROADMAP.md @@ -54,7 +54,7 @@ name. 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 21A: +implemented through Unit 21B: - Inspect derives typed Preparation eligibility and capability projections from verified dataset-run or model-sweep metadata; @@ -88,12 +88,15 @@ implemented through Unit 21A: 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. Its normal Inspect - integration remains the separate Unit 21B boundary. + 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. Integrated audit presentation, lifecycle hardening, -packaged qualification, and native acceptance remain later Stage 5 work. +preparation science. Lifecycle hardening, packaged qualification, and native +acceptance remain later Stage 5 work. ## Reviewed future direction — Optional PyTorch dataset export diff --git a/README.md b/README.md index 0820511..4d6d5e8 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ Workspace → Dataset → YAML Preview → Run → Inspect → Visualization - **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 @@ -190,9 +190,9 @@ 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. A typed Preparation audit view -is packaged and directly tested, while its normal Inspect integration, -lifecycle hardening, packaged qualification, and acceptance remain unfinished. +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 @@ -460,9 +460,9 @@ The accepted direction is workflow depth now, source breadth next, and advanced 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 is in progress: the complete structured Model Sweep and Preparation -editors are enabled and the typed Preparation audit view is packaged for its -pending Inspect integration, while lifecycle hardening, packaged qualification, -and acceptance remain. After that milestone, Carnopy +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. diff --git a/docs/agent-guides/SCIENTIFIC_CONTRACTS.md b/docs/agent-guides/SCIENTIFIC_CONTRACTS.md index 1cfd4cb..103c099 100644 --- a/docs/agent-guides/SCIENTIFIC_CONTRACTS.md +++ b/docs/agent-guides/SCIENTIFIC_CONTRACTS.md @@ -144,11 +144,12 @@ 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, and an enabled structured editor. 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 +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. diff --git a/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml b/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml index 94e041f..8392aae 100644 --- a/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml @@ -14,6 +14,9 @@ Item { 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 @@ -28,6 +31,12 @@ Item { 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; @@ -450,6 +459,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 { @@ -1020,7 +1037,8 @@ 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 @@ -1039,6 +1057,62 @@ Item { } } } + + 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/tests/test_app_qml_inspection.py b/tests/test_app_qml_inspection.py index b816a42..8f8b6bd 100644 --- a/tests/test_app_qml_inspection.py +++ b/tests/test_app_qml_inspection.py @@ -105,6 +105,76 @@ def _accept_preparation_eligible_inspection( ) +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, @@ -159,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: @@ -306,3 +386,72 @@ def test_inspect_page_binds_and_explicitly_clears_a_preparation_source( 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 == () From bc526045c68d4359c9f9c07e0ba483c4343660b6 Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 06:58:24 +0200 Subject: [PATCH 39/45] fix(app): harden preparation acceptance flow --- DESKTOP_ARCHITECTURE.md | 8 ++ GUI2_PLAN.md | 12 ++ src/carnopy/app/qml/Carnopy/Main.qml | 75 +++++++++++ .../app/qml/Carnopy/components/AppButton.qml | 13 +- .../components/PreparationScenarioEditor.qml | 105 ++++++++-------- .../app/qml/Carnopy/pages/InspectPage.qml | 22 ++-- .../app/qml/Carnopy/pages/PreparationPage.qml | 119 +++++++++++++++--- src/carnopy/app/qml_runtime.py | 80 ++++++++++++ tests/test_app_qml_preparation.py | 74 ++++++++++- 9 files changed, 427 insertions(+), 81 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index 4d66067..ebd68e9 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -836,6 +836,14 @@ 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 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. Scenario controls never mutate +or destroy their active Loader or list models synchronously from the originating +click handler. + 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 diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index 6239103..ee8b8a4 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -313,6 +313,18 @@ 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. Focused QML interaction coverage invokes those visible +controls rather than substituting direct Python calls. Native reinspection of +the repaired path remains required before this checkpoint is accepted. + 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 diff --git a/src/carnopy/app/qml/Carnopy/Main.qml b/src/carnopy/app/qml/Carnopy/Main.qml index 8a10f08..d8faa2a 100644 --- a/src/carnopy/app/qml/Carnopy/Main.qml +++ b/src/carnopy/app/qml/Carnopy/Main.qml @@ -48,6 +48,29 @@ ApplicationWindow { signal datasetReloadRequested(bool discardConfirmed) signal sweepNewRequested(bool discardConfirmed) signal preparationNewRequested(bool discardConfirmed) + 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 runCancelRequested signal runForceStopRequested signal runGenerateRequested @@ -1170,6 +1193,57 @@ ApplicationWindow { inspectionController: root.inspectionController objectName: "preparationPage" onInspectSourceRequested: root.routeTo("inspect") + onNewPreparationRequested: root.requestPreparationNew() + 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) onWorkspaceRequested: root.routeTo("workspace") preparationDraft: root.configController.preparationDraft workflowController: root.preparationWorkflowController @@ -1262,6 +1336,7 @@ ApplicationWindow { 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) 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/PreparationScenarioEditor.qml b/src/carnopy/app/qml/Carnopy/components/PreparationScenarioEditor.qml index 304834e..39a2c34 100644 --- a/src/carnopy/app/qml/Carnopy/components/PreparationScenarioEditor.qml +++ b/src/carnopy/app/qml/Carnopy/components/PreparationScenarioEditor.qml @@ -25,6 +25,22 @@ Card { 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; @@ -95,8 +111,7 @@ Card { Layout.fillWidth: true enabled: !root.locked objectName: "preparationScenarioName" - onEditingFinished: root.desktopController.requestPreparationScenarioFieldChange( - root.draft, "name", text) + onEditingFinished: root.scenarioFieldChangeRequested(root.draft, "name", text) selectByMouse: true text: root.draft.name } @@ -150,8 +165,7 @@ Card { enabled: !root.locked inputMethodHints: Qt.ImhDigitsOnly objectName: "preparationScenarioSeed" - onEditingFinished: root.desktopController.requestPreparationScenarioFieldChange( - root.draft, "seed", text) + onEditingFinished: root.scenarioFieldChangeRequested(root.draft, "seed", text) selectByMouse: true text: root.draft.seedText } @@ -177,9 +191,8 @@ Card { enabled: !root.locked model: root.draft.fieldChoices objectName: "preparationScenarioField" - onActivated: root.desktopController.requestPreparationScenarioFieldChange(root.draft, - "field", String( - currentValue)) + onActivated: root.scenarioFieldChangeRequested(root.draft, "field", String( + currentValue)) } } @@ -203,9 +216,8 @@ Card { enabled: !root.locked model: ["train", "validation", "test"] objectName: "preparationScenarioRemainder" - onActivated: root.desktopController.requestPreparationScenarioFieldChange(root.draft, - "remainder", - String(currentValue)) + onActivated: root.scenarioFieldChangeRequested(root.draft, "remainder", String( + currentValue)) } } } @@ -264,8 +276,8 @@ Card { Accessible.name: qsTr("Ratio for %1 partition").arg(partitionRow.partition) enabled: !root.locked objectName: "preparationScenarioPartitionRatio-" + partitionRow.index - onEditingFinished: root.desktopController.requestPreparationScenarioPartition( - root.draft, partitionRow.partition, text) + onEditingFinished: root.scenarioPartitionRequested(root.draft, + partitionRow.partition, text) selectByMouse: true text: String(partitionRow.ratio) } @@ -273,8 +285,8 @@ Card { AppButton { compact: true enabled: !root.locked - onClicked: root.desktopController.requestPreparationScenarioRemovePartition( - root.draft, partitionRow.partition) + onClicked: root.scenarioRemovePartitionRequested(root.draft, + partitionRow.partition) text: qsTr("Remove") } } @@ -304,9 +316,9 @@ Card { AppButton { enabled: !root.locked - onClicked: root.desktopController.requestPreparationScenarioPartition(root.draft, - String(partitionName.currentValue), - partitionRatio.text) + onClicked: root.scenarioPartitionRequested(root.draft, String( + partitionName.currentValue), + partitionRatio.text) text: qsTr("Set") } } @@ -363,8 +375,7 @@ Card { AppButton { compact: true enabled: !root.locked - onClicked: root.desktopController.requestPreparationScenarioRemoveHoldout( - root.draft, holdoutRow.partition) + onClicked: root.scenarioRemoveHoldoutRequested(root.draft, holdoutRow.partition) text: qsTr("Remove") } } @@ -432,20 +443,15 @@ Card { onClicked: { const partition = String(holdoutPartition.currentValue); if (root.categoricalHoldoutKind) - root.desktopController.requestPreparationScenarioCategoricalHoldout(root.draft, - partition, - categoricalValues.text); + root.scenarioCategoricalHoldoutRequested(root.draft, partition, + categoricalValues.text); else if (root.draft.kind === "range_holdout") - root.desktopController.requestPreparationScenarioRangeHoldout(root.draft, - partition, - minimumValue.text, - maximumValue.text); + root.scenarioRangeHoldoutRequested(root.draft, partition, minimumValue.text, + maximumValue.text); else - root.desktopController.requestPreparationScenarioCoordinateHoldout(root.draft, - partition, - String(holdoutField.currentValue), - minimumValue.text, - maximumValue.text); + root.scenarioCoordinateHoldoutRequested(root.draft, partition, String( + holdoutField.currentValue), + minimumValue.text, maximumValue.text); } text: qsTr("Set holdout") } @@ -468,8 +474,7 @@ Card { Layout.fillWidth: true enabled: !root.locked objectName: "preparationScenarioStrataFields" - onEditingFinished: root.desktopController.requestPreparationScenarioStrata(root.draft, - text) + onEditingFinished: root.scenarioStrataRequested(root.draft, text) placeholderText: qsTr("fluid, phase") selectByMouse: true text: root.draft.strataCategoricalText @@ -499,9 +504,9 @@ Card { AppButton { enabled: !root.locked - onClicked: root.desktopController.requestPreparationScenarioNumericBins(root.draft, - String(binField.currentValue), - binBoundaries.text) + onClicked: root.scenarioNumericBinsRequested(root.draft, String( + binField.currentValue), + binBoundaries.text) text: qsTr("Set bins") } } @@ -534,8 +539,8 @@ Card { AppButton { compact: true enabled: !root.locked - onClicked: root.desktopController.requestPreparationScenarioRemoveNumericBins( - root.draft, numericBinRow.field) + onClicked: root.scenarioRemoveNumericBinsRequested(root.draft, + numericBinRow.field) text: qsTr("Remove") } } @@ -582,24 +587,26 @@ Card { AppButton { compact: true enabled: !root.locked && transformationRow.index > 0 - onClicked: root.desktopController.requestPreparationScenarioTransformationMove( - root.draft, transformationRow.index, transformationRow.index - 1) + 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.desktopController.requestPreparationScenarioTransformationMove( - root.draft, transformationRow.index, transformationRow.index + 1) + onClicked: root.scenarioTransformationMoveRequested(root.draft, + transformationRow.index, + transformationRow.index + 1) text: qsTr("Down") } AppButton { compact: true enabled: !root.locked - onClicked: root.desktopController.requestPreparationScenarioTransformationRemove( - root.draft, transformationRow.index) + onClicked: root.scenarioTransformationRemoveRequested(root.draft, + transformationRow.index) text: qsTr("Remove") } } @@ -638,9 +645,9 @@ Card { AppButton { enabled: !root.locked - onClicked: root.desktopController.requestPreparationScenarioTransformationAdd( - root.draft, String(transformField.currentValue), - transformMethods.text) + onClicked: root.scenarioTransformationAddRequested(root.draft, String( + transformField.currentValue), + transformMethods.text) text: qsTr("Add") } } @@ -683,9 +690,7 @@ Card { "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.desktopController.requestPreparationScenarioKindChange(root.draft, - root.pendingKind, - true); + root.scenarioKindChangeRequested(root.draft, root.pendingKind, true); root.pendingKind = ""; } onRejected: root.pendingKind = "" diff --git a/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml b/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml index 8392aae..fbc5632 100644 --- a/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/InspectPage.qml @@ -24,6 +24,7 @@ Item { signal inspectSourceRequested(string path) signal moreSourcesRequested + signal preparationRequested signal preparationSourceBindRequested signal preparationSourceClearRequested signal previewPageRequested(int pageOffset) @@ -513,7 +514,7 @@ Item { 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") - RowLayout { + Flow { Layout.fillWidth: true spacing: Theme.spacingSmall @@ -533,12 +534,7 @@ Item { ? "success" : "neutral") } - Item { - Layout.fillWidth: true - } - AppButton { - compact: true enabled: root.preparationWorkflowController.inspectedSourceAvailable && root.inspectionController.canInspect objectName: "preparationBindSourceButton" @@ -548,7 +544,9 @@ Item { root.preparationWorkflowController.inspectedSourceMatchesBinding ? qsTr("Used for ML Preparation") : qsTr( "Use for ML Preparation")) - visible: root.inspectionController.preparationEligible + tone: "primary" + visible: root.inspectionController.preparationEligible && + !root.preparationWorkflowController.inspectedSourceMatchesBinding ToolTip.text: enabled ? qsTr( "Bind this exact verified inspection revision for ML Preparation.") : @@ -557,7 +555,6 @@ Item { } AppButton { - compact: true enabled: root.preparationWorkflowController.hasBoundSource && root.inspectionController.canInspect objectName: "preparationClearSourceButton" @@ -566,6 +563,15 @@ Item { 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 { diff --git a/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml b/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml index 00caf37..2583e54 100644 --- a/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml @@ -29,6 +29,29 @@ Item { signal categoryModeDialogRequested signal inspectSourceRequested + signal newPreparationRequested + 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 workspaceRequested function reveal(item) { @@ -169,11 +192,32 @@ Item { title: qsTr("No ML Preparation configuration is active") visible: !root.documentActive - AppButton { - objectName: "preparationOpenWorkspaceButton" - onClicked: root.workspaceRequested() - text: qsTr("Open Workspace") - tone: "primary" + 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" + } } } @@ -635,7 +679,7 @@ Item { Accessible.description: qsTr("Open a temporary Preparation scenario editor") enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit objectName: "preparationAddScenario" - onClicked: root.desktopController.requestPreparationAddScenario() + onClicked: root.scenarioAddRequested() text: qsTr("Add scenario") tone: "primary" } @@ -678,8 +722,7 @@ Item { compact: true enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit objectName: "preparationScenarioEdit-" + scenarioRow.index - onClicked: root.desktopController.requestPreparationEditScenario( - scenarioRow.index) + onClicked: root.scenarioEditRequested(scenarioRow.index) text: qsTr("Edit") } @@ -690,8 +733,8 @@ Item { enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit && scenarioRow.index > 0 objectName: "preparationScenarioUp-" + scenarioRow.index - onClicked: root.desktopController.requestPreparationMoveScenario( - scenarioRow.index, scenarioRow.index - 1) + onClicked: root.scenarioMoveRequested(scenarioRow.index, + scenarioRow.index - 1) text: qsTr("Up") } @@ -702,8 +745,8 @@ Item { enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit && scenarioRow.index + 1 < scenarioList.count objectName: "preparationScenarioDown-" + scenarioRow.index - onClicked: root.desktopController.requestPreparationMoveScenario( - scenarioRow.index, scenarioRow.index + 1) + onClicked: root.scenarioMoveRequested(scenarioRow.index, + scenarioRow.index + 1) text: qsTr("Down") } @@ -712,8 +755,7 @@ Item { compact: true enabled: !root.locked && !root.preparationDraft.hasActiveScenarioEdit objectName: "preparationScenarioRemove-" + scenarioRow.index - onClicked: root.desktopController.requestPreparationRemoveScenario( - scenarioRow.index) + onClicked: root.scenarioRemoveRequested(scenarioRow.index) text: qsTr("Remove") } } @@ -744,8 +786,53 @@ Item { dialogsEnabled: root.dialogsEnabled draft: root.preparationDraft.activeScenarioDraft locked: root.locked - onCancelRequested: root.desktopController.requestPreparationCancelScenario() - onCommitRequested: root.desktopController.requestPreparationCommitScenario() + 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) } } } diff --git a/src/carnopy/app/qml_runtime.py b/src/carnopy/app/qml_runtime.py index 31407da..3bae464 100644 --- a/src/carnopy/app/qml_runtime.py +++ b/src/carnopy/app/qml_runtime.py @@ -490,6 +490,86 @@ def _connect_qml_facade(self, root: QObject) -> None: ("datasetNewRequested", self.controller.request_new_dataset), ("sweepNewRequested", self.controller.request_new_sweep), ("preparationNewRequested", self.controller.request_new_preparation), + ( + "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, + ), ( "configurationImportRequested", self.controller.request_import_configuration, diff --git a/tests/test_app_qml_preparation.py b/tests/test_app_qml_preparation.py index 3e39b9e..9285fef 100644 --- a/tests/test_app_qml_preparation.py +++ b/tests/test_app_qml_preparation.py @@ -273,9 +273,14 @@ def test_shell_requires_a_bound_source_then_enables_the_preparation_surface( _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") @@ -315,6 +320,70 @@ def test_shell_requires_a_bound_source_then_enables_the_preparation_surface( assert runtime.warning_capture.runtime_warnings == () +def test_shell_queues_scenario_lifecycle_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() + assert runtime.warning_capture.runtime_warnings == () + + def test_scenario_editor_binds_the_complete_temporary_surface( runtime: QmlApplicationRuntime, scenario_editor: QQuickItem, @@ -416,7 +485,9 @@ def test_preparation_scenario_qml_resource_and_controller_boundary_are_explicit( assert "PreparationScenarioEditor 1.0 components/PreparationScenarioEditor.qml" in qmldir assert "qml/Carnopy/components/PreparationScenarioEditor.qml" in MANDATORY_QML_FILES - assert "requestPreparationScenario" in source + 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 @@ -595,6 +666,7 @@ def test_preparation_page_qml_resource_and_controller_boundary_are_explicit() -> assert ".setRoleSelected(" not in source assert ".beginAddScenario(" not in source assert ".commitScenario(" 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") From 398383226dbf27e54894224f7f9204d14ab1290a Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 07:17:10 +0200 Subject: [PATCH 40/45] fix(app): queue preparation page mutations --- DESKTOP_ARCHITECTURE.md | 15 +-- GUI2_PLAN.md | 10 +- src/carnopy/app/qml/Carnopy/Main.qml | 31 +++++++ .../app/qml/Carnopy/pages/PreparationPage.qml | 93 +++++++++---------- src/carnopy/app/qml_runtime.py | 32 +++++++ tests/test_app_qml_preparation.py | 21 ++++- 6 files changed, 142 insertions(+), 60 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index ebd68e9..9e856a6 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -836,13 +836,14 @@ 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 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. Scenario controls never mutate -or destroy their active Loader or list models synchronously from the originating -click handler. +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. The QML Inspect workbench consumes only the typed Qt models owned by `InspectionController`. Workspace discovery is direct-child, symlink-excluding, diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index ee8b8a4..11d2658 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -321,9 +321,13 @@ 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. Focused QML interaction coverage invokes those visible -controls rather than substituting direct Python calls. Native reinspection of -the repaired path remains required before this checkpoint is accepted. +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 of the repaired path +remains required before this checkpoint is accepted. No public YAML schema, CLI command, Python API, scientific algorithm, manifest, result model, artifact layout, provenance contract, or dependency boundary has diff --git a/src/carnopy/app/qml/Carnopy/Main.qml b/src/carnopy/app/qml/Carnopy/Main.qml index d8faa2a..b8c499d 100644 --- a/src/carnopy/app/qml/Carnopy/Main.qml +++ b/src/carnopy/app/qml/Carnopy/Main.qml @@ -48,6 +48,13 @@ ApplicationWindow { 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 @@ -71,6 +78,7 @@ ApplicationWindow { 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 @@ -1192,8 +1200,28 @@ ApplicationWindow { 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) @@ -1244,6 +1272,9 @@ ApplicationWindow { 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 diff --git a/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml b/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml index 2583e54..8cb628e 100644 --- a/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml @@ -27,9 +27,16 @@ Item { 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 @@ -52,6 +59,9 @@ Item { 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) { @@ -314,7 +324,7 @@ Item { && root.inspectionController.canInspect && !root.workflowController.operationActive objectName: "preparationUseInspectedSource" - onClicked: root.desktopController.requestBindInspectedPreparationSource() + onClicked: root.sourceBindRequested() text: root.workflowController.boundSourceRefreshAvailable ? qsTr( "Use refreshed source") : qsTr("Use inspected source") @@ -334,7 +344,7 @@ Item { enabled: root.workflowController.hasBoundSource && !root.workflowController.operationActive objectName: "preparationClearSource" - onClicked: root.desktopController.requestClearPreparationSource(false) + onClicked: root.sourceClearRequested(false) text: qsTr("Clear source") tone: "danger" visible: root.workflowController.hasBoundSource @@ -385,8 +395,8 @@ Item { checked: parent.selected enabled: !root.locked && (parent.compatible || parent.selected) objectName: "preparationNumeric-" + parent.value - onClicked: root.desktopController.requestPreparationRoleSelection( - "numeric", parent.value, checked) + onClicked: root.roleSelectionRequested("numeric", parent.value, + checked) text: parent.display ToolTip.text: parent.issue @@ -430,8 +440,8 @@ Item { checked: parent.selected enabled: !root.locked && (parent.compatible || parent.selected) objectName: "preparationDerived-" + parent.value - onClicked: root.desktopController.requestPreparationRoleSelection( - "derived", parent.value, checked) + onClicked: root.roleSelectionRequested("derived", parent.value, + checked) text: parent.display ToolTip.text: parent.issue @@ -474,8 +484,8 @@ Item { checked: parent.selected enabled: !root.locked && (parent.compatible || parent.selected) objectName: "preparationTarget-" + parent.value - onClicked: root.desktopController.requestPreparationRoleSelection( - "target", parent.value, checked) + onClicked: root.roleSelectionRequested("target", parent.value, + checked) text: parent.display ToolTip.text: parent.issue @@ -519,8 +529,8 @@ Item { checked: parent.selected enabled: !root.locked && (parent.compatible || parent.selected) objectName: "preparationAuxiliary-" + parent.value - onClicked: root.desktopController.requestPreparationRoleSelection( - "auxiliary", parent.value, checked) + onClicked: root.roleSelectionRequested("auxiliary", parent.value, + checked) text: parent.display ToolTip.text: parent.issue @@ -569,9 +579,8 @@ Item { enabled: !root.locked && (categoryRow.compatible || categoryRow.selected) objectName: "preparationCategorical-" + categoryRow.value - onClicked: - root.desktopController.requestPreparationCategoricalSelection( - categoryRow.value, checked) + onClicked: root.categoricalSelectionRequested(categoryRow.value, + checked) text: categoryRow.display ToolTip.text: categoryRow.issue @@ -601,8 +610,8 @@ Item { root.pendingCategoryMode = selectedMode; root.categoryModeDialogRequested(); } else { - root.desktopController.requestPreparationCategoryMode( - categoryRow.value, selectedMode, false); + root.categoryModeRequested(categoryRow.value, selectedMode, + false); } } visible: categoryRow.selected @@ -614,9 +623,8 @@ Item { Layout.fillWidth: true enabled: !root.locked && categoryRow.selected objectName: "preparationExplicitCategories-" + categoryRow.value - onEditingFinished: - root.desktopController.requestPreparationExplicitCategories( - categoryRow.value, text) + onEditingFinished: root.explicitCategoriesRequested( + categoryRow.value, text) placeholderText: qsTr("Comma-separated categories") selectByMouse: true text: root.preparationDraft.explicit_categories_text( @@ -666,8 +674,7 @@ Item { enabled: !root.locked && root.workflowController.boundSourceKind === "model_sweep" objectName: "preparationAllowPartialSweep" - onClicked: root.desktopController.requestPreparationBooleanField( - "allow_partial_sweep", checked) + onClicked: root.booleanFieldRequested("allow_partial_sweep", checked) text: qsTr("Allow eligible partial Sweep sources") } @@ -871,8 +878,7 @@ Item { checked: root.preparationDraft.arrayOutputsEnabled enabled: !root.locked objectName: "preparationArrayOutputs" - onClicked: root.desktopController.requestPreparationBooleanField( - "array_outputs", checked) + onClicked: root.booleanFieldRequested("array_outputs", checked) text: qsTr("Array artifacts") } @@ -902,9 +908,8 @@ Item { checked: parent.selected enabled: !root.locked && (parent.compatible || parent.selected) objectName: "preparationArrayFormat-" + parent.value - onClicked: - root.desktopController.requestPreparationArrayFormatSelection( - parent.value, checked) + onClicked: root.arrayFormatSelectionRequested(parent.value, + checked) text: parent.display ToolTip.text: parent.issue @@ -921,8 +926,7 @@ Item { enabled: !root.locked && root.preparationDraft.arrayOutputsEnabled model: ["float32", "float64"] objectName: "preparationArrayDtype" - onActivated: root.desktopController.requestPreparationTextField( - "array_dtype", String(currentValue)) + onActivated: root.textFieldRequested("array_dtype", String(currentValue)) visible: root.preparationDraft.arrayOutputsEnabled } @@ -931,8 +935,7 @@ Item { checked: root.preparationDraft.includeAuxiliary enabled: !root.locked && root.preparationDraft.arrayOutputsEnabled objectName: "preparationArrayIncludeAuxiliary" - onClicked: root.desktopController.requestPreparationBooleanField( - "include_auxiliary", checked) + onClicked: root.booleanFieldRequested("include_auxiliary", checked) text: qsTr("Include auxiliary fields in arrays") visible: root.preparationDraft.arrayOutputsEnabled } @@ -953,8 +956,7 @@ Item { checked: root.preparationDraft.matrixDiagnosticsEnabled enabled: !root.locked objectName: "preparationMatrixDiagnostics" - onClicked: root.desktopController.requestPreparationBooleanField( - "matrix_diagnostics", checked) + onClicked: root.booleanFieldRequested("matrix_diagnostics", checked) text: qsTr("Matrix diagnostics") } @@ -969,8 +971,8 @@ Item { Layout.fillWidth: true enabled: !root.locked objectName: "preparationCorrelationThreshold" - onEditingFinished: root.desktopController.requestPreparationTextField( - "correlation_threshold", text) + onEditingFinished: root.textFieldRequested("correlation_threshold", + text) placeholderText: qsTr("Correlation threshold") selectByMouse: true text: root.preparationDraft.correlationThreshold @@ -981,7 +983,7 @@ Item { Layout.fillWidth: true enabled: !root.locked objectName: "preparationNearConstantSpread" - onEditingFinished: root.desktopController.requestPreparationTextField( + onEditingFinished: root.textFieldRequested( "near_constant_relative_spread", text) placeholderText: qsTr("Near-constant spread") selectByMouse: true @@ -1000,8 +1002,7 @@ Item { root.preparationDraft.baselineDiagnosticsAvailable || root.preparationDraft.baselineDiagnosticsEnabled) objectName: "preparationBaselineDiagnostics" - onClicked: root.desktopController.requestPreparationBooleanField( - "baseline_diagnostics", checked) + onClicked: root.booleanFieldRequested("baseline_diagnostics", checked) text: qsTr("Baseline diagnostics") } @@ -1043,9 +1044,8 @@ Item { checked: parent.selected enabled: !root.locked && (parent.compatible || parent.selected) objectName: "preparationBaselineModel-" + parent.value - onClicked: - root.desktopController.requestPreparationBaselineModelSelection( - parent.value, checked) + onClicked: root.baselineModelSelectionRequested(parent.value, + checked) text: parent.display ToolTip.text: parent.issue @@ -1066,8 +1066,7 @@ Item { Layout.fillWidth: true enabled: !root.locked objectName: "preparationBaselineSeed" - onEditingFinished: root.desktopController.requestPreparationTextField( - "baseline_random_seed", text) + onEditingFinished: root.textFieldRequested("baseline_random_seed", text) placeholderText: qsTr("Random seed") selectByMouse: true text: root.preparationDraft.baselineRandomSeed @@ -1078,8 +1077,7 @@ Item { Layout.fillWidth: true enabled: !root.locked objectName: "preparationRidgeAlpha" - onEditingFinished: root.desktopController.requestPreparationTextField( - "ridge_alpha", text) + onEditingFinished: root.textFieldRequested("ridge_alpha", text) placeholderText: qsTr("Ridge alpha") selectByMouse: true text: root.preparationDraft.ridgeAlpha @@ -1090,8 +1088,8 @@ Item { Layout.fillWidth: true enabled: !root.locked objectName: "preparationHistogramIterations" - onEditingFinished: root.desktopController.requestPreparationTextField( - "histogram_max_iterations", text) + onEditingFinished: root.textFieldRequested("histogram_max_iterations", + text) placeholderText: qsTr("Maximum iterations") selectByMouse: true text: root.preparationDraft.histogramMaxIterations @@ -1131,9 +1129,8 @@ Item { "Using source-observed categories discards the temporary explicit category list for this field.") objectName: "preparationCategoryModeDialog" onAccepted: { - root.desktopController.requestPreparationCategoryMode(root.pendingCategoryField, - root.pendingCategoryMode, - true); + root.categoryModeRequested(root.pendingCategoryField, root.pendingCategoryMode, + true); root.pendingCategoryField = ""; root.pendingCategoryMode = ""; } diff --git a/src/carnopy/app/qml_runtime.py b/src/carnopy/app/qml_runtime.py index 3bae464..2e85d61 100644 --- a/src/carnopy/app/qml_runtime.py +++ b/src/carnopy/app/qml_runtime.py @@ -490,6 +490,34 @@ def _connect_qml_facade(self, root: QObject) -> None: ("datasetNewRequested", self.controller.request_new_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, @@ -570,6 +598,10 @@ def _connect_qml_facade(self, root: QObject) -> None: "preparationScenarioTransformationMoveRequested", self.controller.request_preparation_scenario_transformation_move, ), + ( + "preparationTextFieldRequested", + self.controller.request_preparation_text_field, + ), ( "configurationImportRequested", self.controller.request_import_configuration, diff --git a/tests/test_app_qml_preparation.py b/tests/test_app_qml_preparation.py index 9285fef..f3d6c4c 100644 --- a/tests/test_app_qml_preparation.py +++ b/tests/test_app_qml_preparation.py @@ -320,7 +320,7 @@ def test_shell_requires_a_bound_source_then_enables_the_preparation_surface( assert runtime.warning_capture.runtime_warnings == () -def test_shell_queues_scenario_lifecycle_from_the_visible_preparation_page( +def test_shell_queues_mutations_from_the_visible_preparation_page( runtime: QmlApplicationRuntime, tmp_path: Path, ) -> None: @@ -381,6 +381,19 @@ def test_shell_queues_scenario_lifecycle_from_the_visible_preparation_page( 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 == () @@ -659,13 +672,17 @@ def test_preparation_page_qml_resource_and_controller_boundary_are_explicit() -> assert "PreparationPage 1.0 pages/PreparationPage.qml" in qmldir assert "qml/Carnopy/pages/PreparationPage.qml" in MANDATORY_QML_FILES - assert "requestPreparation" in source + 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 From f20dbfce3442a872d92bee5b930c1c254cb9c014 Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 07:29:38 +0200 Subject: [PATCH 41/45] fix(app): label and align preparation diagnostics --- DESKTOP_ARCHITECTURE.md | 5 +- GUI2_PLAN.md | 9 +- .../app/qml/Carnopy/pages/PreparationPage.qml | 159 +++++++++++++----- tests/test_app_qml_preparation.py | 60 ++++++- 4 files changed, 186 insertions(+), 47 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index 9e856a6..2feed1e 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -843,7 +843,10 @@ 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. +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. The QML Inspect workbench consumes only the typed Qt models owned by `InspectionController`. Workspace discovery is direct-child, symlink-excluding, diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index 11d2658..bb22951 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -326,8 +326,13 @@ 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 of the repaired path -remains required before this checkpoint is accepted. +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 of that presentation repair remains +required before this checkpoint is accepted. No public YAML schema, CLI command, Python API, scientific algorithm, manifest, result model, artifact layout, provenance contract, or dependency boundary has diff --git a/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml b/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml index 8cb628e..947af53 100644 --- a/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml +++ b/src/carnopy/app/qml/Carnopy/pages/PreparationPage.qml @@ -856,6 +856,7 @@ Item { Card { id: outputsCard + Layout.alignment: Qt.AlignTop Layout.fillWidth: true activeFocusOnTab: true objectName: "preparationOutputsCard" @@ -942,6 +943,7 @@ Item { } Card { + Layout.alignment: Qt.AlignTop Layout.fillWidth: true activeFocusOnTab: true objectName: "preparationQualityCard" @@ -966,28 +968,56 @@ Item { objectName: "preparationMatrixSettingsGrid" visible: root.preparationDraft.matrixDiagnosticsEnabled - TextField { - Accessible.name: qsTr("Correlation threshold") + ColumnLayout { Layout.fillWidth: true - enabled: !root.locked - objectName: "preparationCorrelationThreshold" - onEditingFinished: root.textFieldRequested("correlation_threshold", - text) - placeholderText: qsTr("Correlation threshold") - selectByMouse: true - text: root.preparationDraft.correlationThreshold + 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 + } } - TextField { - Accessible.name: qsTr("Near-constant relative spread") + ColumnLayout { Layout.fillWidth: true - enabled: !root.locked - objectName: "preparationNearConstantSpread" - onEditingFinished: root.textFieldRequested( - "near_constant_relative_spread", text) - placeholderText: qsTr("Near-constant spread") - selectByMouse: true - text: root.preparationDraft.nearConstantRelativeSpread + 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 + } } } @@ -1061,38 +1091,81 @@ Item { objectName: "preparationBaselineSettingsGrid" visible: root.preparationDraft.baselineDiagnosticsEnabled - TextField { - Accessible.name: qsTr("Baseline random seed") + ColumnLayout { Layout.fillWidth: true - enabled: !root.locked - objectName: "preparationBaselineSeed" - onEditingFinished: root.textFieldRequested("baseline_random_seed", text) - placeholderText: qsTr("Random seed") - selectByMouse: true - text: root.preparationDraft.baselineRandomSeed + 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 + } } - TextField { - Accessible.name: qsTr("Ridge alpha") + ColumnLayout { Layout.fillWidth: true - enabled: !root.locked - objectName: "preparationRidgeAlpha" - onEditingFinished: root.textFieldRequested("ridge_alpha", text) - placeholderText: qsTr("Ridge alpha") - selectByMouse: true - text: root.preparationDraft.ridgeAlpha + 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 + } } - TextField { - Accessible.name: qsTr("Histogram maximum iterations") + ColumnLayout { Layout.fillWidth: true - enabled: !root.locked - objectName: "preparationHistogramIterations" - onEditingFinished: root.textFieldRequested("histogram_max_iterations", - text) - placeholderText: qsTr("Maximum iterations") - selectByMouse: true - text: root.preparationDraft.histogramMaxIterations + 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 + } } } } diff --git a/tests/test_app_qml_preparation.py b/tests/test_app_qml_preparation.py index f3d6c4c..2ed7bf9 100644 --- a/tests/test_app_qml_preparation.py +++ b/tests/test_app_qml_preparation.py @@ -11,7 +11,15 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") pytest.importorskip("PySide6") -from PySide6.QtCore import QCoreApplication, QEventLoop, QMetaObject, QObject, QSettings, QTimer +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 @@ -569,6 +577,56 @@ def test_preparation_page_binds_the_complete_authoritative_editor( 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, From 597f4cb2ccfc5b786125103df9c4445afb2322f6 Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 07:36:03 +0200 Subject: [PATCH 42/45] docs(gui2): record preparation native acceptance --- DESKTOP_ARCHITECTURE.md | 4 +++- GUI2_PLAN.md | 14 ++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index 2feed1e..4899780 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -846,7 +846,9 @@ 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. +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, diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index bb22951..da0bf67 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -331,8 +331,10 @@ 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 of that presentation repair remains -required before this checkpoint is accepted. +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. No public YAML schema, CLI command, Python API, scientific algorithm, manifest, result model, artifact layout, provenance contract, or dependency boundary has @@ -345,10 +347,10 @@ The remaining implementation order after Unit 21B is: 2. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. 3. Unit 24 records completion only after automated and manual acceptance. -The Unit 21B commit is the audit-presentation checkpoint for another -normal-application inspection before lifecycle work continues. Lifecycle paths -are inspected after Unit 22, and the final installed application after Unit 23; -acceptance must not be deferred until the documentation-only completion unit. +The Unit 21B audit-presentation checkpoint and its focused native repair cycle +are complete. Lifecycle paths are inspected after Unit 22, and the final +installed application after Unit 23; acceptance must not be deferred until the +documentation-only completion unit. ## Stage 6: exact scientific 3D scenes From c1291e10d2ce260e5a4f2c9d2826c357d2dd9007 Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 16:47:47 +0200 Subject: [PATCH 43/45] fix(app): reject obsolete worker responses --- DESKTOP_ARCHITECTURE.md | 30 +++++- GUI2_PLAN.md | 31 +++++- src/carnopy/app/config_controller.py | 117 +++++++++++++++++++- src/carnopy/app/inspection_controller.py | 49 +++++++++ src/carnopy/app/workflow_controller.py | 82 +++++++++++++- tests/test_app_config_controller.py | 100 +++++++++++++++++ tests/test_app_inspection_controller.py | 132 ++++++++++++++++++++++- tests/test_app_workflow_controller.py | 52 +++++++++ 8 files changed, 574 insertions(+), 19 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index 4899780..e851e22 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -216,6 +216,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 @@ -1103,7 +1118,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 | In progress through Unit 21B; both editors and typed Preparation audit inspection enabled, lifecycle hardening pending | +| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 22A; semantic response guards complete, action and shutdown hardening 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 | @@ -1174,7 +1189,7 @@ 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 21B on `feat/gui2-stage5`. The former +Stage 5 is implemented through Unit 22A 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 @@ -1199,9 +1214,14 @@ 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. Lifecycle -hardening, packaged qualification, complete gates, native acceptance, and -completion documentation remain unfinished. This checkpoint changes private +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. Remaining action and +shutdown lifecycle hardening, packaged qualification, complete gates, native +acceptance, and completion documentation remain unfinished. This checkpoint +changes private desktop ownership and presentation infrastructure only; public scientific and distribution contracts remain unchanged. diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index da0bf67..ea8fa02 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -211,7 +211,7 @@ 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–21B +### Implementation checkpoint: Units 1–22A Stage 5 is in progress on `feat/gui2-stage5`. The implemented checkpoint keeps one globally active configuration document while extending its exact-byte, @@ -336,19 +336,42 @@ 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 remain separate Units 22B and 22C. + 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 21B is: +The remaining implementation order after Unit 22A is: -1. Unit 22 hardens cross-workflow lifecycle and semantic response guards. +1. Units 22B and 22C complete cross-workflow action, shutdown, and transition + hardening. 2. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. 3. Unit 24 records completion only after automated and manual acceptance. The Unit 21B audit-presentation checkpoint and its focused native repair cycle -are complete. Lifecycle paths are inspected after Unit 22, and the final +are complete. Lifecycle paths are inspected after Unit 22C, and the final installed application after Unit 23; acceptance must not be deferred until the documentation-only completion unit. diff --git a/src/carnopy/app/config_controller.py b/src/carnopy/app/config_controller.py index c8d036c..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 @@ -35,6 +36,16 @@ from carnopy.templates import template_text +@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.""" @@ -80,6 +91,8 @@ def __init__( 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 @@ -616,6 +629,7 @@ def apply_coordinate_change(self, selected: str) -> bool: 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 @@ -726,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, @@ -746,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 @@ -755,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: @@ -775,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: @@ -867,6 +897,7 @@ def _apply_capabilities(self, payload: dict[str, Any]) -> None: self.state_changed.emit() def _clear_document(self) -> None: + self._document_generation += 1 self.document = None self._syncing_document = True try: @@ -884,7 +915,13 @@ def _clear_document(self) -> None: 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 @@ -894,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" @@ -1034,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 diff --git a/src/carnopy/app/inspection_controller.py b/src/carnopy/app/inspection_controller.py index 22afa0f..d5c2403 100644 --- a/src/carnopy/app/inspection_controller.py +++ b/src/carnopy/app/inspection_controller.py @@ -35,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.""" @@ -68,6 +79,7 @@ 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 = "" @@ -765,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: @@ -802,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": @@ -935,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 @@ -948,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": @@ -1007,6 +1055,7 @@ 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() diff --git a/src/carnopy/app/workflow_controller.py b/src/carnopy/app/workflow_controller.py index 6e5916d..2df9451 100644 --- a/src/carnopy/app/workflow_controller.py +++ b/src/carnopy/app/workflow_controller.py @@ -41,6 +41,16 @@ 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 @@ -100,6 +110,7 @@ def __init__( 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 = "" @@ -666,6 +677,7 @@ def _start( 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) @@ -700,6 +712,7 @@ def _start( 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) @@ -756,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 @@ -778,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" @@ -803,7 +830,12 @@ def _request_completed(self, value: object) -> 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") @@ -813,11 +845,52 @@ 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 + 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") @@ -925,6 +998,7 @@ 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 diff --git a/tests/test_app_config_controller.py b/tests/test_app_config_controller.py index 1b6fa1c..7c141b0 100644 --- a/tests/test_app_config_controller.py +++ b/tests/test_app_config_controller.py @@ -354,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, @@ -553,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, diff --git a/tests/test_app_inspection_controller.py b/tests/test_app_inspection_controller.py index 8cd2538..04a8bcb 100644 --- a/tests/test_app_inspection_controller.py +++ b/tests/test_app_inspection_controller.py @@ -7,13 +7,14 @@ 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 @@ -22,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, @@ -708,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: diff --git a/tests/test_app_workflow_controller.py b/tests/test_app_workflow_controller.py index 3fbd2f9..8956ddb 100644 --- a/tests/test_app_workflow_controller.py +++ b/tests/test_app_workflow_controller.py @@ -1477,6 +1477,58 @@ def test_failed_workflow_load_retains_previous_plan_as_stale( 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, From 0854736d0f14fa7fdeec75397bb2114b69d97c80 Mon Sep 17 00:00:00 2001 From: gca Date: Sat, 15 Aug 2026 17:51:56 +0200 Subject: [PATCH 44/45] fix(app): enforce desktop action guards --- DESKTOP_ARCHITECTURE.md | 31 +++- GUI2_PLAN.md | 29 +++- src/carnopy/app/desktop_controller.py | 177 ++++++++++++++-------- tests/test_app_desktop_controller.py | 206 ++++++++++++++++++++++++++ 4 files changed, 373 insertions(+), 70 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index e851e22..19c6562 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -199,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: @@ -1118,7 +1130,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 | In progress through Unit 22A; semantic response guards complete, action and shutdown hardening pending | +| 5 | Add structured sweep and preparation QML workflows | In progress through Unit 22B; semantic response and direct-action guards complete, shutdown hardening 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 | @@ -1189,7 +1201,7 @@ 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 22A on `feat/gui2-stage5`. The former +Stage 5 is implemented through Unit 22B 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 @@ -1218,12 +1230,15 @@ 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. Remaining action and -shutdown lifecycle hardening, packaged qualification, complete gates, native -acceptance, and completion documentation remain unfinished. This checkpoint -changes private -desktop ownership and presentation infrastructure only; public scientific and -distribution contracts remain unchanged. +sources and preview blocks must match their requests. Remaining shutdown and +transition lifecycle hardening, packaged qualification, complete gates, native +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. Unit 22C retains shutdown and the +remaining transition matrix. This checkpoint changes private desktop ownership +and presentation infrastructure only; public scientific and distribution +contracts remain unchanged. ## Known current limitations diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index ea8fa02..71d8cc7 100644 --- a/GUI2_PLAN.md +++ b/GUI2_PLAN.md @@ -211,7 +211,7 @@ 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–22A +### Implementation checkpoint: Units 1–22B Stage 5 is in progress on `feat/gui2-stage5`. The implemented checkpoint keeps one globally active configuration document while extending its exact-byte, @@ -356,16 +356,37 @@ 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 remain separate Units 22B and 22C. +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. Unit 22C still +owns multi-step shutdown and the remaining cross-workflow transition matrix. 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 22A is: +The remaining implementation order after Unit 22B is: -1. Units 22B and 22C complete cross-workflow action, shutdown, and transition +1. Unit 22C completes multi-step shutdown and cross-workflow transition hardening. 2. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. 3. Unit 24 records completion only after automated and manual acceptance. diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index 38e48de..dcaeab2 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -504,19 +504,19 @@ 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_configuration_lifecycle("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_configuration_lifecycle("New Model Sweep"): + if not self._guard_idle_configuration_action("New Model Sweep"): return False 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_configuration_lifecycle("New ML Preparation"): + if not self._guard_idle_configuration_action("New ML Preparation"): return False return self.configuration_controller.new_preparation(discard_confirmed) @@ -526,7 +526,7 @@ def request_import_configuration( path: str, discard_confirmed: bool = False, ) -> bool: - if not self._guard_configuration_lifecycle("Open Configuration"): + if not self._guard_idle_configuration_action("Open Configuration"): return False return self.configuration_controller.import_configuration( _local_path(path), @@ -535,7 +535,7 @@ def request_import_configuration( @Slot(str, bool, result=bool, name="requestImportDataset") def request_import_dataset(self, path: str, discard_confirmed: bool = False) -> bool: - if not self._guard_configuration_lifecycle("Import"): + if not self._guard_idle_configuration_action("Import"): return False return self.configuration_controller.import_dataset( _local_path(path), @@ -544,28 +544,32 @@ def request_import_dataset(self, path: str, discard_confirmed: bool = False) -> @Slot(bool, result=bool, name="requestSave") def request_save(self, allow_reformat: bool = False) -> bool: - if not self._guard_configuration_lifecycle("Save"): + if not self._guard_idle_configuration_action("Save"): return False 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_configuration_lifecycle("Save As"): + if not self._guard_idle_configuration_action("Save As"): return False return self.configuration_controller.request_save_as(allow_reformat) @Slot(result=bool, name="requestValidateConfiguration") def request_validate_configuration(self) -> bool: - if not self._guard_configuration_lifecycle("Validation"): + if not self._guard_idle_configuration_action("Validation"): return False 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") @@ -579,12 +583,20 @@ def request_execution_force_stop(self) -> bool: @Slot(str, result=bool, name="requestWorkflowPlan") def request_workflow_plan(self, workflow: str) -> bool: controller = self._workflow_controller(workflow) - return False if controller is None else controller.plan() + 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) - return False if controller is None else controller.execute() + 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: @@ -729,7 +741,8 @@ 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_configuration_lifecycle("Save As"): + if not self._guard_idle_configuration_action("Save As"): + self.configuration_controller.cancel_save_path() return False return self.configuration_controller.save_path_selected(_local_path(path)) @@ -739,18 +752,18 @@ def request_cancel_save_path(self) -> None: @Slot(str, name="requestConfirmReformat") def request_confirm_reformat(self, action: str) -> None: - if self._guard_configuration_lifecycle("Save"): + 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_configuration_lifecycle("Reload"): + if not self._guard_idle_configuration_action("Reload"): return False 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_configuration_lifecycle("Close Configuration"): + if not self._guard_idle_configuration_action("Close Configuration"): return False return self.configuration_controller.clear_document(discard_confirmed) @@ -767,7 +780,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_configuration_lifecycle("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 @@ -779,7 +794,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_configuration_lifecycle("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 @@ -794,7 +811,11 @@ 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_configuration_lifecycle("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 @@ -814,10 +835,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: @@ -825,14 +849,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: @@ -840,20 +868,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") @@ -864,13 +895,13 @@ 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") @@ -1382,28 +1413,38 @@ def request_preparation_scenario_transformation_move( @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 @@ -1419,21 +1460,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) @@ -1550,23 +1595,22 @@ def _owned_dataset_sampler(self, candidate: QObject) -> SamplerDraft | None: None, ) + def _can_edit_dataset_document(self) -> bool: + return self._can_edit_document("dataset", "Dataset") + def _can_edit_sweep_document(self) -> bool: - if self.configuration_controller.get_document_kind() != "model_sweep": - return False - if self.configuration_controller.get_can_edit(): - return True - self.workspace_controller.report_error( - "Wait for the active worker request before editing the Model Sweep configuration." - ) - return False + return self._can_edit_document("model_sweep", "Model Sweep") def _can_edit_preparation_document(self) -> bool: - if self.configuration_controller.get_document_kind() != "preparation": + 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( - "Wait for the active worker request before editing the ML Preparation configuration." + f"Wait for the active worker request before editing the {label} configuration." ) return False @@ -1597,18 +1641,18 @@ def _owned_preparation_scenario(self, candidate: QObject) -> ScenarioDraft | Non return active if isinstance(active, ScenarioDraft) and active is candidate else 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(), - ) - return next( - ( - active - for active in active_drafts - if isinstance(active, PlotDraft) and active is candidate - ), - 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, @@ -1619,6 +1663,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 @@ -1629,8 +1675,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") @@ -1980,6 +2027,16 @@ def _guard_workspace_change(self, *, before_commit: bool) -> bool: 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 @@ -2165,6 +2222,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/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index d899eeb..2e62d06 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -657,6 +657,187 @@ def test_workflow_facade_routes_sweep_and_preparation_control( 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, @@ -1606,9 +1787,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( { @@ -1664,6 +1852,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() @@ -1675,6 +1875,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") From c9523e285351f6b207f2db97ebf6ea4cc77f1be1 Mon Sep 17 00:00:00 2001 From: gca Date: Sun, 16 Aug 2026 02:47:16 +0200 Subject: [PATCH 45/45] fix(app): harden multi-step shutdown --- DESKTOP_ARCHITECTURE.md | 36 ++- GUI2_PLAN.md | 46 +++- src/carnopy/app/desktop_controller.py | 197 +++++++++++++++-- src/carnopy/app/qml/Carnopy/Main.qml | 10 +- tests/test_app_desktop_controller.py | 306 +++++++++++++++++++++++++- tests/test_app_qml_runtime.py | 35 +++ 6 files changed, 580 insertions(+), 50 deletions(-) diff --git a/DESKTOP_ARCHITECTURE.md b/DESKTOP_ARCHITECTURE.md index 19c6562..afd5780 100644 --- a/DESKTOP_ARCHITECTURE.md +++ b/DESKTOP_ARCHITECTURE.md @@ -834,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 @@ -1130,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 | In progress through Unit 22B; semantic response and direct-action guards complete, shutdown hardening pending | +| 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 | @@ -1201,7 +1215,7 @@ 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 22B on `feat/gui2-stage5`. The former +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 @@ -1230,15 +1244,17 @@ 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. Remaining shutdown and -transition lifecycle hardening, packaged qualification, complete gates, native -acceptance, and completion documentation remain unfinished. Direct desktop -slots now enforce global request idleness, transient-editor focus, document +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. Unit 22C retains shutdown and the -remaining transition matrix. This checkpoint changes private desktop ownership -and presentation infrastructure only; public scientific and distribution -contracts remain unchanged. +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 diff --git a/GUI2_PLAN.md b/GUI2_PLAN.md index 71d8cc7..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 | In progress | Structured Sweep, Preparation, and typed audit inspection are enabled; lifecycle hardening and qualification remain | +| 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 | @@ -211,7 +211,7 @@ 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–22B +### 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, @@ -376,24 +376,48 @@ 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. Unit 22C still -owns multi-step shutdown and the remaining cross-workflow transition matrix. +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 22B is: +The remaining implementation order after Unit 22C is: -1. Unit 22C completes multi-step shutdown and cross-workflow transition - hardening. -2. Unit 23 qualifies packaged Stage 5 QML and runs the complete gate. -3. Unit 24 records completion only after automated and manual acceptance. +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. Lifecycle paths are inspected after Unit 22C, and the final -installed application after Unit 23; acceptance must not be deferred until the +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 diff --git a/src/carnopy/app/desktop_controller.py b/src/carnopy/app/desktop_controller.py index dcaeab2..9a9e20d 100644 --- a/src/carnopy/app/desktop_controller.py +++ b/src/carnopy/app/desktop_controller.py @@ -154,6 +154,15 @@ def __init__( self.workspace_controller.pending_operation_changed.connect( self.workspace_confirmation_changed ) + 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 @@ -172,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 @@ -1772,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" @@ -1809,16 +1836,22 @@ 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") @@ -1833,23 +1866,35 @@ def request_shutdown(self) -> bool: f"A {description} edit is still open. Cancel the edit and close Carnopy?" ) return False - if ( - self.configuration_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() @@ -1864,23 +1909,38 @@ def confirm_busy_shutdown(self, confirmed: bool) -> bool: 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(): @@ -1895,13 +1955,22 @@ def confirm_transient_edit_shutdown(self, discard_confirmed: bool) -> bool: and not self.configuration_controller.preparation_draft.cancel_scenario() ): return False - self.closeWindowRequested.emit() + 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_configuration_lifecycle("closing Carnopy"): return False @@ -1912,9 +1981,9 @@ 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 @@ -1936,6 +2005,7 @@ 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() @@ -1946,6 +2016,8 @@ def _preparation_source_binding_changed(self) -> None: 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) @@ -1960,6 +2032,12 @@ def _continue_pending_busy_shutdown(self) -> None: 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" @@ -1993,9 +2071,19 @@ def _continue_pending_busy_shutdown(self) -> None: 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: @@ -2011,8 +2099,77 @@ 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() diff --git a/src/carnopy/app/qml/Carnopy/Main.qml b/src/carnopy/app/qml/Carnopy/Main.qml index b8c499d..990185b 100644 --- a/src/carnopy/app/qml/Carnopy/Main.qml +++ b/src/carnopy/app/qml/Carnopy/Main.qml @@ -1427,8 +1427,9 @@ 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(); } @@ -1666,7 +1667,10 @@ ApplicationWindow { title: busyMode === "force_stop_plot" ? qsTr("Stop plot render and close?") : (busyMode === "cancel_sweep" ? qsTr("Cancel Model Sweep and close?") : - qsTr("Cancel generation and close?")) + (busyMode + === "cancel_preparation" + ? qsTr("Cancel ML Preparation and close?") : + qsTr("Cancel generation and close?"))) } FileDialog { diff --git a/tests/test_app_desktop_controller.py b/tests/test_app_desktop_controller.py index 2e62d06..13a4c68 100644 --- a/tests/test_app_desktop_controller.py +++ b/tests/test_app_desktop_controller.py @@ -226,6 +226,8 @@ def test_qml_shutdown_requires_explicit_dirty_discard_confirmation( 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() @@ -435,14 +437,21 @@ def test_qml_shutdown_explicitly_cancels_transient_plot_edits_before_close( 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: True) + 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", - lambda: cancellations.append("session") or True, + cancel_session_edit, ) assert not desktop.request_shutdown() @@ -452,6 +461,11 @@ def test_qml_shutdown_explicitly_cancels_transient_plot_edits_before_close( 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"] @@ -467,13 +481,20 @@ def test_qml_shutdown_explicitly_cancels_a_transient_sweep_edit_before_close( 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: True) + 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", - lambda: cancellations.append("comparison") or True, + cancel_comparison, ) assert not desktop.request_shutdown() @@ -483,6 +504,11 @@ def test_qml_shutdown_explicitly_cancels_a_transient_sweep_edit_before_close( 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"] @@ -498,13 +524,24 @@ def test_qml_shutdown_explicitly_cancels_a_transient_preparation_edit_before_clo 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: True) + 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", - lambda: cancellations.append("scenario") or True, + cancel_scenario, ) assert not desktop.request_shutdown() @@ -514,11 +551,268 @@ def test_qml_shutdown_explicitly_cancels_a_transient_preparation_edit_before_clo 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, diff --git a/tests/test_app_qml_runtime.py b/tests/test_app_qml_runtime.py index b8e41ce..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,