diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c08a4d3..1067e994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: env: QT_QPA_PLATFORM: offscreen run: python tools/run_ci_tests.py + - name: Verify actual Windows workbench cases + if: always() + run: python tools/verify_prediction_workbench_ci.py - name: Preserve Python test evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/desktop/capture_desktop/screen_analysis.py b/desktop/capture_desktop/screen_analysis.py index f1fa58d8..83bd8e5f 100644 --- a/desktop/capture_desktop/screen_analysis.py +++ b/desktop/capture_desktop/screen_analysis.py @@ -8,15 +8,17 @@ from pathlib import Path from typing import Any -from PySide6.QtCore import QObject, QThread, Signal, Slot +from PySide6.QtCore import QEvent, QObject, Qt, QThread, Signal, Slot from PySide6.QtWidgets import ( QComboBox, QFileDialog, + QFrame, QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, + QScrollArea, QSplitter, QTextEdit, QVBoxLayout, @@ -144,7 +146,15 @@ def __init__( self._build_controls(left_layout) self._mappings = AnatomicalMappingPanel(persistence) left_layout.addWidget(self._mappings) - splitter.addWidget(left) + controls_scroll = QScrollArea() + controls_scroll.setObjectName("AnalysisControlsScroll") + controls_scroll.setWidgetResizable(True) + controls_scroll.setFrameShape(QFrame.Shape.NoFrame) + controls_scroll.setMinimumWidth( + left.minimumWidth() + controls_scroll.verticalScrollBar().sizeHint().width() + ) + controls_scroll.setWidget(left) + splitter.addWidget(controls_scroll) self._gallery = FigureGallery() splitter.addWidget(self._gallery) @@ -158,6 +168,24 @@ def __init__( self.package_loaded.connect(self._sources.load_package) self._sources.changed.connect(self._sync_enabled) + def event(self, event: QEvent) -> bool: + if ( + event.type() == QEvent.Type.ShortcutOverride + and event.key() == Qt.Key.Key_Space + and event.modifiers() == Qt.KeyboardModifier.NoModifier + ): + focus = self.focusWidget() + if ( + isinstance(focus, QPushButton) + and focus.hasFocus() + and self.isAncestorOf(focus) + ): + # The application's checkpoint shortcut must not consume the + # native Space activation of a focused Analysis button. + event.accept() + return True + return super().event(event) + def _build_controls(self, root: QVBoxLayout) -> None: path_row = QHBoxLayout() self._path = QLabel("No package selected") @@ -333,7 +361,8 @@ def _source_error(self) -> str | None: return self._sources.error_for(str(self._command.currentData() or "qc")) def _sync_enabled(self) -> None: - busy = self._thread is not None and self._thread.isRunning() + busy = self._thread is not None + self._extras.set_eval_busy(busy) locked = self._recording_locked() has_pkg = self._summary is not None scope_error = self._scope_error() @@ -396,7 +425,7 @@ def _append_log(self, line: str) -> None: self._log.append(line) def _start_job(self) -> None: - if self._summary is None or (self._thread and self._thread.isRunning()): + if self._summary is None or self._thread is not None: return if self._recording_locked(): QMessageBox.warning( diff --git a/desktop/capture_desktop/widgets_analysis_jobs.py b/desktop/capture_desktop/widgets_analysis_jobs.py index c4d2f451..8f65859d 100644 --- a/desktop/capture_desktop/widgets_analysis_jobs.py +++ b/desktop/capture_desktop/widgets_analysis_jobs.py @@ -18,6 +18,7 @@ ) from . import theme +from .widgets_analysis_predictions import EvaluationInput def list_job_ids(package: Path, *, require_rel: str | None = None) -> list[str]: @@ -47,6 +48,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._box = QGroupBox("Job dependencies") form = QFormLayout(self._box) + self._dependency_form = form self._pose_job = QComboBox() self._pose_job.setEditable(True) self._kin_job = QComboBox() @@ -67,6 +69,7 @@ def __init__(self, parent: QWidget | None = None) -> None: win_row.addWidget(QLabel("Hop s")) win_row.addWidget(self._hop_sec) form.addRow("ML windows", win_row) + self._non_eval_rows = (self._pose_job, self._kin_job, self._feat_job, win_row) root.addWidget(self._box) hint = QLabel( @@ -78,8 +81,12 @@ def __init__(self, parent: QWidget | None = None) -> None: hint.setFont(theme.ui(8)) root.addWidget(hint) + self._evaluation = EvaluationInput() + root.addWidget(self._evaluation) + def set_package(self, package: str) -> None: self._package = package + self._evaluation.set_package(package) self.refresh() def refresh(self) -> None: @@ -127,9 +134,15 @@ def set_command(self, command: str) -> None: self._bundle_job.setEnabled(need_bundle) self._window_sec.setEnabled(need_win) self._hop_sec.setEnabled(need_win) + for row in self._non_eval_rows: + self._dependency_form.setRowVisible(row, command != "eval") self.setVisible( command in ("pose", "kinematics", "ml_bundle", "eval") ) + self._evaluation.set_command(command) + + def set_eval_busy(self, busy: bool) -> None: + self._evaluation.set_busy(busy) def build_extra(self, command: str) -> dict: extra: dict = {} @@ -150,6 +163,7 @@ def build_extra(self, command: str) -> dict: bid = self._bundle_job.currentText().strip() if bid: extra["ml_bundle_job_id"] = bid + extra.update(self._evaluation.extra()) return extra def validate_for(self, command: str) -> str | None: @@ -163,6 +177,8 @@ def validate_for(self, command: str) -> str | None: return "Select a features job (radar preferred)." if command == "eval" and not extra.get("ml_bundle_job_id"): return "Select an ml_bundle job." + if command == "eval": + return self._evaluation.validate() return None diff --git a/desktop/capture_desktop/widgets_analysis_predictions.py b/desktop/capture_desktop/widgets_analysis_predictions.py new file mode 100644 index 00000000..0e472549 --- /dev/null +++ b/desktop/capture_desktop/widgets_analysis_predictions.py @@ -0,0 +1,155 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Explicit evaluation input for the offline Analysis workbench.""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QComboBox, + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from . import theme + + +class EvaluationInput(QGroupBox): + """Choose a path; the existing evaluator admits its bytes when the job runs.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__("Evaluation input", parent) + self.setObjectName("EvaluationInput") + self._package = "" + self._command = "eval" + self._revision = 0 + self._busy = False + root = QVBoxLayout(self) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(6) + + self._mode = QComboBox() + self._mode.setObjectName("EvalInputMode") + self._mode.setAccessibleName("Evaluation input mode") + self._mode.addItem("Identity teacher (simulation)", "identity") + self._mode.addItem("External predictions file", "external") + root.addWidget(self._mode) + + self._file_row = QWidget() + file_layout = QHBoxLayout(self._file_row) + file_layout.setContentsMargins(0, 0, 0, 0) + file_layout.setSpacing(6) + self._path = QLineEdit() + self._path.setObjectName("EvalPredictionPath") + self._path.setAccessibleName("Predictions JSON file") + self._path.setReadOnly(True) + self._path.setMinimumWidth(0) + self._path.setPlaceholderText("No predictions file chosen") + self._choose_button = QPushButton("Choose…") + self._choose_button.setObjectName("EvalPredictionChoose") + self._choose_button.setAccessibleName("Choose predictions JSON file") + self._choose_button.clicked.connect(self._choose) + self._clear_button = QPushButton("Clear") + self._clear_button.setObjectName("EvalPredictionClear") + self._clear_button.setAccessibleName("Clear predictions file choice") + self._clear_button.clicked.connect(self._clear) + file_layout.addWidget(self._path, 1) + file_layout.addWidget(self._choose_button) + file_layout.addWidget(self._clear_button) + root.addWidget(self._file_row) + + self._help = QLabel() + self._help.setObjectName("Dim") + self._help.setWordWrap(True) + self._help.setTextFormat(Qt.TextFormat.PlainText) + self._help.setFont(theme.ui(8)) + root.addWidget(self._help) + self._mode.currentIndexChanged.connect(self._mode_changed) + self._refresh() + + def _mode_changed(self) -> None: + self._revision += 1 + self._refresh() + + def _refresh(self) -> None: + external = self._mode.currentData() == "external" + self._file_row.setVisible(external) + self._clear_button.setEnabled(bool(self._path.text())) + self._path.setToolTip(self._path.text()) + self._help.setText( + "Compares supplied predictions with the selected ML bundle. " + "Source and window identities are checked before scoring." + if external + else "Fixture simulation: compares each teacher value with itself." + ) + + def set_package(self, package: str) -> None: + identity = str(Path(package).resolve()) if package else "" + if identity != self._package: + self._package = identity + self._revision += 1 + self._path.clear() + self._refresh() + + def set_command(self, command: str) -> None: + if command != self._command: + self._command = command + self._revision += 1 + self.setVisible(command == "eval") + + def set_busy(self, busy: bool) -> None: + if busy != self._busy: + self._busy = busy + self._revision += 1 + self.setEnabled(not busy) + + def _choose(self) -> None: + if not self.isEnabled() or self._command != "eval": + return + if self._mode.currentData() != "external": + return + self._revision += 1 + revision = self._revision + filename, _selected_filter = QFileDialog.getOpenFileName( + self, + "Choose external predictions JSON", + self._path.text() or self._package, + "Prediction JSON (*.json);;All files (*)", + ) + if revision != self._revision or not self.isEnabled() or not filename: + return + # Preserve the exact native choice, including significant whitespace. + self._path.setText(filename) + self._refresh() + + def _clear(self) -> None: + self._revision += 1 + self._path.clear() + self._refresh() + self._choose_button.setFocus() + + def extra(self) -> dict[str, str]: + if self._mode.currentData() != "external": + return {} + # An empty external path remains explicit even if a caller skips validation. + return {"prediction_path": self._path.text()} + + def validate(self) -> str | None: + if self._mode.currentData() != "external": + return None + filename = self._path.text() + if not filename: + return "Choose a predictions JSON file for external evaluation." + try: + if Path(filename).is_file(): + return None + except (OSError, ValueError): + pass + return "The predictions file is unavailable. Choose an existing JSON file and run again." diff --git a/docs/design/ANALYSIS.md b/docs/design/ANALYSIS.md index 13fbc44f..d24f789b 100644 --- a/docs/design/ANALYSIS.md +++ b/docs/design/ANALYSIS.md @@ -524,3 +524,21 @@ held-out participants, calibration, teacher accuracy or clinical validity. Without --predictions, the prior identity_teacher_sim behavior is preserved. Verification: python -m pytest tests/analysis/test_external_predictions.py. + +### Native workbench: supplied prediction files + +In the Analysis tab, select **Eval report**, the source **ML bundle job**, and +**External predictions file** under **Evaluation input**. **Choose…** selects +the JSON file; **Run** sends that explicit choice through the existing job +worker and evaluator. Empty or unavailable external choices block the run and +never select the identity simulation. **Identity teacher (simulation)** remains +the explicit default for fixture checks. + +The control column scrolls at smaller window sizes. The workbench preserves a +choice after dialog cancellation and clears it when a different session is +opened. A running job captures its request, disables the evaluation-input controls, and offers the +existing cancellation action. The evaluator retains the bytes it receives when +the job executes; selecting a file does not freeze its contents. + +See [Evaluate a predictions file in Analysis](research/EXTERNAL_EVALUATION_WORKBENCH.md) +for the operator flow, file-choice behavior and retained report/figure paths. diff --git a/docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md b/docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md new file mode 100644 index 00000000..30f9c1c0 --- /dev/null +++ b/docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md @@ -0,0 +1,91 @@ +# Evaluate a predictions file in Analysis + +The Analysis workbench can compare a supplied predictions JSON file with an +existing ML bundle. It uses the same evaluator and source checks as the +`eval --predictions` command. + +## Run an evaluation + +1. Open the sealed or recovered session in **Analysis**, use **Full session** + scope, and select **Eval report**. +2. Select the completed **ML bundle job** whose windows were used to produce + the predictions. +3. In **Evaluation input**, select **External predictions file**. +4. Select **Choose…** and open the predictions JSON file for that bundle. + The read-only filename field and its tooltip identify the selected file. +5. Select **Run**. Review the job status and diagnostics. If the evaluator + refuses the input, correct the file or choose another one and run again. +6. Inspect the outputs in **Job inspector**. Use **Export figures…** to preview + and save selected recorded PNGs with the job's provenance, or use + **Open job folder** on Windows to inspect the report and retained inputs. + +The controls column scrolls when the window is short. Keyboard focus scrolls +the focused control into view, including the file buttons and **Run**. + +The input format, target coverage, file limits and scoring rules are documented +in [External prediction evaluation](../ANALYSIS.md#decision-2026-10-08-evaluate-externally-supplied-predictions). +Choose the file for the exact selected bundle: matching the model label alone +does not establish the required source and window identities. + +## Choose the intended input + +| Evaluation input | What runs | +|---|---| +| **Identity teacher (simulation)** | The existing fixture check compares each teacher value with itself. A previously selected external file is not used in this mode. | +| **External predictions file** | The evaluator checks and compares the supplied values. An empty or unavailable choice blocks Run; it never falls back to the simulation. | + +**Cancel** in the file dialog leaves the current file choice intact. +**Clear** removes the choice and keeps external mode selected; choose another +file before running. Clearing moves keyboard focus to **Choose…**. + +Changing commands or switching between the two input modes preserves the file +choice within the same session. Opening a different session clears the file +choice while retaining the explicit mode. The choice is local to this +workbench instance and is not saved as a desktop preference. + +Starting a job captures its session, bundle ID and evaluation input choice. +The evaluation input controls are disabled until that worker finishes. +The choice stores a path: the evaluator reads and retains the file bytes when +the job executes. Finish writing the predictions file before starting the job. + +## Read the result + +An external evaluation writes these artifacts under the new job's `eval/` +directory: + +- `eval_report.json`: comparison mode, caller-supplied model label, metrics, + exclusions and input provenance. +- `prediction_input.json`: the exact predictions file received by the evaluator. +- `source_bundle_manifest.json`: the source bundle manifest used by the job. +- `predictions.parquet`: paired teacher/prediction/error rows. +- `figures/`: per-target PNG and PDF figures. + +The ordinary `params.json` and `job_manifest.json` identify the request and +record final output sizes and digests. A completed job may retain warnings; +read them with the source context. + +On the current workbench, evaluation figures are available through +**Export figures…** and the job folder. The central **Sync** tab is populated +by features/plots jobs, so an empty Sync dashboard after Eval does not mean +the evaluation failed. + +The external report identifies `external_predictions` and has no identity +baseline. Identity mode retains `identity_teacher_sim`. Both remain +provisional research outputs. Supplied values and matching source hashes do +not establish model authorship, a held-out study design, calibration or +clinical validity. Analysis leaves the source files and raw capture unchanged. + +## Maintainer verification + +The input UI is isolated in `widgets_analysis_predictions.py`; the existing +worker and evaluator receive the selected path through ordinary job extras. +No evaluator formula, prediction schema or job-publication rule is replaced. + +```bash +python -m pytest tests/ui/test_analysis_predictions.py +``` + +Native receiving and the exact supported-platform CI result are retained with +the feature's source qualification. The local Mac offscreen Qt run exercises +actual widgets, file dialogs and background jobs; it is not a Windows hardware +or OS-native file-dialog qualification. diff --git a/docs/design/research/PROGRESS.md b/docs/design/research/PROGRESS.md index 084b6434..132437f8 100644 --- a/docs/design/research/PROGRESS.md +++ b/docs/design/research/PROGRESS.md @@ -1050,3 +1050,55 @@ history/selection/completion, backend jobs/pipeline, galleries and raw storage remain with their current owners. The ownership fence is coordinated in #55 and HAMON #140. Details: ANALYSIS_SOURCE_SELECTION.md and docs/evidence/analysis-sources-234cae4aee53/. + +## 2026-10-08 — Native external-prediction evaluation (#69; HAMON 6e5752b49b6f) + +The native Analysis workbench now exposes the existing #57/PR65 evaluator with +an explicit **Evaluation input** choice. **Identity teacher (simulation)** +retains the original default. **External predictions file** offers a native +file chooser, literal read-only path, Clear and actionable missing-file errors. +It sends the ordinary prediction_path job extra without replacing evaluator +admission, source binding, scoring, schema or publication. Source and raw files +remain read-only. See [the operator guide](EXTERNAL_EVALUATION_WORKBENCH.md). + +Chooser cancellation preserves the current choice. A new session clears the +file; command/mode changes preserve it within the same session. Revision checks +retire file dialogs whose context changed while they were open. The worker +captures scalar request values, while the evaluator admits and retains the +actual input bytes when it runs. No file-byte snapshot or trained-model +execution is implied by a selection. + +Native receiving exposed two separate workbench findings. The first candidate +clipped its new controls under vertical pressure, so the unchanged control +column now lives in a native scroll area. Keyboard controls, wrapped text and +Run/Cancel remain reachable without forcing a taller Analysis window. An +independent held-worker check also proved the parent's Run/Cancel state used +isRunning before thread.start and never resynchronized. The shared screen hunk +now treats a reserved worker as busy and rejects any duplicate start while it +is reserved; the existing worker/cancellation/job lifecycle is unchanged. + +The original absence capture, functional source, clipped images, inherited busy +counterexample and receiver-only corrections remain distinct in qualification. +Local Python 3.12.8 / PySide6 6.11.2 receiving uses actual Qt dialogs and QThreads, +real external/identity/refused jobs, retained-file and output-inventory checks, +and keyboard reachability at 1100×700 and 1280×960. The actual MainWindow imports +Windows named-pipe APIs before its auto_connect=False guard, so its minimum-size +case is a supported Windows CI gate, explicitly skipped on Mac. No Windows +hardware, OS-native dialog, model authorship or scientific efficacy claim is +made from the local fixture evidence. Independent receiving and hosted results +are recorded separately with their exact source identities. + + +### PR84 Windows chooser receiving correction (2026-10-08) + +The first ordinary Windows gate timed out before JUnit. An isolated unchanged-source full-order diagnostic identified the actual Qt test helper stripping a significant leading filename space, then waiting inside a file-not-found QMessageBox. Production chooser behavior is unchanged. The helper now uses verified literal filename entry and independent bounded modal cleanup, with an actual missing-file unwind regression. The normal 300-second runner and selection remain exact; a read-only post-Pytest step prints and checks the actual named MainWindow and chooser JUnit cases. Original failure and diagnostic evidence are preserved in `docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/`; supported Windows acceptance remains pending until that actual successor gate completes. + + +### PR84 exact Windows diagnosis and current picker composition (2026-10-08) + +The second unchanged-source diagnostic separates three actual failures and a later inherited receiving stall: inline CI observation violated the one-command contract; even quoted filename entry lost significant leading space through Qt's model; MainWindow's global checkpoint Space shortcut consumed native Clear activation; the existing scope-test QTest.qWait loop stalled a real plotting worker. Original ordinary and diagnostic failures are retained, not relabeled. The next candidate uses the independently received actual file-view selection approach, retains bounded modal cleanup, protects only unmodified Space on a focused descendant Analysis QPushButton, and preserves global shortcut behavior in a real countercontrol. JUnit observation moves to one explicit Python command. The scope wait correction is independently owned and preserves its original receiving assertions/bound. The full current source picker and current progress text are preserved in this composition. The unchanged ordinary300-second Windows gate must qualify the resulting exact checkout before adoption. + + +### PR84 exact file-model boundary and owned scope receiving (2026-10-08) + +The unchanged-source Windows probe directly distinguished the literal OS filename from Qt6.12 widget-model normalization. Successful real chooser receiving now uses a representable Unicode/internal-space filename; the original leading-space fixture separately requires exact identity or bounded refusal with prior path unchanged, recording the actual branch in JUnit. No product path normalization or native Windows-dialog claim is introduced. The exact owner-authored PR79 scope wait blob817412fe is adopted with estate-7879c2abc07f credit, preserving every existing bound/assertion and excluding its unrelated QC source. The current picker, narrow focused-button Space correction, one-command read-only JUnit gate, original failures and native receiving packets are preserved. Ordinary full-suite Windows acceptance is still pending for this exact composition. diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/AUTHOR_RECEIVING.md b/docs/evidence/analysis-predictions-6e5752b49b6f/AUTHOR_RECEIVING.md new file mode 100644 index 00000000..e7ee796a --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/AUTHOR_RECEIVING.md @@ -0,0 +1,66 @@ +# CaptureSuite #69 — native external-prediction workbench receiving + +## Result and source identity + +The native Analysis workbench exposes the existing external-prediction evaluator through an explicit mode, exact file choice, validation and the existing background-job path. It retains the identity-teacher simulation as the explicit default. The final scoped UI source is accepted by separate functional, geometry/cancellation and root source receivers. The supported Windows MainWindow case remains a hosted CI gate at the time this packet is frozen. + +Final executed layout source: 983641cc270db34be0711d554cd2c82608cb0b8c, tree 4cceca0d9ce05a354584c7a2e3092e5e59e6c7c9. Publication source: 4987c99c49751461a6b53a90ce9c43e39062ad59, tree 2d418e2094fae2caa59b17be66e1e7524ace66f0, declared parent 3cbb5300bdc79c157370d1b3e599a6c82752d54a. The four executed runtime/test files and operator guide are byte-identical in publication. ANALYSIS and PROGRESS append to the actual current parent; no owner content is replaced. + +| Final path | SHA-256 | +| --- | --- | +| desktop/capture_desktop/widgets_analysis_predictions.py | d865031e313b0c1aed23a2cb0beeb20441a96d380f0b3652458107ba4a6e2435 | +| desktop/capture_desktop/widgets_analysis_jobs.py | a012ac150e1301a630417cd78238c050093206f94f073e6e0ee4c90f22b81b62 | +| desktop/capture_desktop/screen_analysis.py | a62e7e9ae63c658205531ef51844f4a68a64c738ce1de6083f93a4e9a5ddbc94 | +| tests/ui/test_analysis_predictions.py | e87f63a19639d40785d922b3e78d27b8487feb51a3c3c46d89ea6184f7facbe2 | + +## User and job behavior + +Choose Eval report and an ML bundle, then External predictions file and Choose. The path remains literal, including significant whitespace and Unicode. Empty or unavailable external choices block normal Run; explicit empty external extra remains a fail-closed backstop in the existing evaluator. Cancellation preserves the current choice. New sessions clear it; same-session command/mode changes retain it. Revision checks retire modal results after changed context. Clear retains external mode and returns focus to Choose. + +The worker copies the scalar request values before execution. Its captured bundle/mode/path survive later editable controls or package activity. File selection is a path, not a byte snapshot: the evaluator admits and retains the actual bytes at execution. Existing source binding, schema, scoring, raw/package protection and job publication remain the evaluator owner's implementation. + +The controls column scrolls at constrained heights. A reserved thread now disables Run and the evaluation-input controls and enables Cancel immediately, while a second start is refused. The existing bundle selector remains editable; its later value does not alter the captured job. Worker lifecycle and cancellation handling are unchanged. + +## Native author execution + +All executions used the already installed Python 3.12.8 / PySide6 and Qt 6.11.2 runtime on Mac, with private temporary, Matplotlib and application-profile directories. QT_QPA_PLATFORM=offscreen used actual Qt widgets/dialogs/QThreads; no OS-native Cocoa dialog or operating-system focus claim is made. No dependencies were installed and no hardware or daemon was called. + +| Record | Exact scope and outcome | +| --- | --- | +| baseline-native | Actual earlier PR65 f6ff65d AnalysisScreen has no prediction mode/path controls; screenshot and absence record preserved. | +| native-r1 / native-debug-r1 | Initial 300-second harness timeout and keyboard failure preserved. qWait polling starved the real worker's Matplotlib drawing; Return was the wrong QPushButton activation assumption. Debug execution nevertheless completed the actual external job. | +| native-r2 | Corrected native QEventLoop/Space harness: 28 passed, two existing protobuf warnings; scoped Ruff passed; all four source files unchanged. This includes 26 new cases and two unchanged workbench cases. | +| native-current | On exact d7459fa composition, three real workbench cases passed; external, identity and refusal/retry outputs and inventories preserved. | +| native-ui | Actual completed external job and dark/light 1280×960 images exposed clipped controls. Retained prediction bytes, report, raw/package and output inventories were checked. | +| native-layout-r2 | On 4e8075a: 28 passed, two geometry tests failed and one Windows-only skip. All essential size/height/help/keyboard assertions passed; the extra assertion incorrectly demanded scroll motion even when Run was already visible. Static import-order failure and correction are separately preserved. | +| native-layout-r3 | On 983641cc: unchanged runtime, corrected conditional scroll assertion. Both size cases passed and the Windows-only MainWindow case was explicitly skipped. Scoped Ruff passed; source unchanged. | + +These are distinct original records, not a fabricated single 30-pass run. The final test file differs from the 4e8075a file only in the condition for demanding scroll movement. Deprecation warnings remain visible in original logs. + +The archive contains the actual inputs/results, original logs/JUnit, exact failed and corrected source snapshots, source manifests and reproduction wrappers. Font caches and completed early temporary fixtures that were safely reproducible were removed only from this worker's directories; the cleanup record describes them. Failure logs and executed source remain retained. Pytest convenience symlinks, if present, are inert metadata rather than followed archive members. + +## Independent receiving and demonstrated defects + +The unchanged companion functional archive binds original source 424298e3: four independent groups, 12 actual Qt file dialogs and six real evaluator jobs. It verifies chooser cancellation/reselection/retirement, exact filenames, external/identity/refusal/retry provenance, queued inputs and execution-time file-byte binding. All imported project files and protected inputs were hash-admitted. + +The unchanged counterexample archive preserves two real defects separately. New controls were compressed below native minimum heights, and requested 1100×700 became 1100×881. Separately, the inherited screen checked isRunning before thread.start and left Run enabled/Cancel disabled during a real held worker. Removing our initial input-disable hook reproduces the whole parent screen byte-for-byte, establishing that second defect's ancestry. + +The successor archive binds exact 983641cc. Both 1100×700 and 1280×960 geometry cases pass with minimum heights, wrapped help and keyboard reachability. Actual Clear → file chooser → keyboard Run → held worker → keyboard Cancel → settled recovery passes; the real evaluator consumes cancellation before outputs. The receiver's forward-Tab assumptions were corrected to Shift+Tab after actual focus diagnostics; both failures and final success retain original provenance. It is not a second run of the six functional jobs. + +The production author also read the final successor guide and inspected four actual final images: both viewport mode views, the held worker and recovery. Controls are readable and unclipped; the state assertions and real cancellation receipt substantiate behavior beyond screenshots. + +Root's independent source-review.json is preserved unchanged (SHA-256 ab0487bd1695fb6d543d39b7cb0bb6fe7a77df98ccd517be86acdae9a5329fa9). It accepts the final source boundary and explicitly leaves current-parent admission and Windows CI to later gates. + +## Current-parent and platform boundaries + +Publication retains the stronger current checkpoint-window helper. Earlier six-job receiving used null start/end/checkpoint and full-session scope; the checkpoint-only delta is outside those executed branches, a source-based inference rather than a replay. Current PR66's runner refuses explicit apply_sync_anchors=True and records false for the ordinary branch; our UI and captured jobs use false. External prediction forwarding and evaluator source remain preserved. Current-parent inventories and independent admission record the exact comparison. + +Unchanged app.py and daemon_link import Windows named-pipe modules before constructing MainWindow, including msvcrt and ctypes.WinDLL. Consequently auto_connect=False does not make the full application importable on Mac. No transport stub was introduced. The permanent Windows-only test instantiates actual MainWindow(auto_connect=False) at its declared minimum 1100×700. Existing windows-2022/Python3.12 CI must execute it; Mac AnalysisScreen proxy results do not replace that supported-platform gate. + +The existing gallery does not directly tab eval/figures images. The guide directs users to the existing Export figures action or Windows Open job folder and preserves the gallery owner's scope. Real scientific validity, trained-model execution, hardware capture, OS-native dialog behavior and installed deployment are outside these synthetic fixture results. + +## Packet verification and reproduction + +MANIFEST.json lists every regular member except itself with its original byte count and SHA-256. The freezer reads the completed gzip tar back, requires the exact member set, rejects non-file members and hashes every byte. The archive is provided with a separate summary containing its SHA-256 and Git blob identity. + +Use the project's declared Python 3.12 dependencies. Exact native commands and private environment settings are in the preserved wrappers and process records. Run against a checkout matching the recorded source; do not relabel older receipts to a newer source. Source snapshots, raw Git commit bodies and path inventories retain the local history without copying an entire repository. Reproduction writes only to fresh fixture/output directories. diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/README.md b/docs/evidence/analysis-predictions-6e5752b49b6f/README.md new file mode 100644 index 00000000..c8796672 --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/README.md @@ -0,0 +1,44 @@ +# Native external-prediction workbench — issue #69 + +The accepted change gives Analysis users an explicit choice between the existing identity-teacher simulation and a supplied predictions JSON file. The native picker preserves literal paths, cancellation and stale-dialog retirement; normal Run refuses unavailable external input. The ordinary worker captures the selected bundle/mode/path, and the existing evaluator binds and retains the actual file bytes when the job executes. + +Two demonstrated workbench defects are also repaired in the shared screen hunk: the existing control column now scrolls instead of compressing controls at smaller heights, and a reserved thread immediately disables Run, enables Cancel and prevents a second launch. The evaluator, JobParams, worker lifecycle, schemas, raw/package handling and figure-export components are unchanged. + +## Qualified source and current-parent preservation + +Source commit **4987c99c49751461a6b53a90ce9c43e39062ad59**, tree **2d418e2094fae2caa59b17be66e1e7524ace66f0**, composes the accepted runtime onto actual main **3cbb5300bdc79c157370d1b3e599a6c82752d54a**, tree **4af105896633559b35823a7aa2710dc30b2f1740**. + +The parent has 1,057 leaves and this source has 1,060. All 1,053 parent leaves outside the four intentional modifications retain exact blobs and modes. There are exactly seven source/docs paths: two existing UI modules, the new input widget, the new test module and guide, and appended ANALYSIS/PROGRESS notes. All four runtime/test files and the guide are byte-identical to independently accepted 983641cc / 4cceca0d. Both current document prefixes are preserved. + +[Source preservation](source-preservation.json) records the author's literal comparison. The [independent current-parent receipt](current-parent-receiving.json) separately compares the full native and API inventories and admits all 66 unowned imported modules. Only the current checkpoint helper and explicit true-anchor refusal differ from the earlier received full-session, false-anchor execution. Those older receipts keep their original source identity. + +## Actual receiving + +| Evidence | Result and original provenance | +| --- | --- | +| [Author record](AUTHOR_RECEIVING.md), [complete archive](author-receiving.tar.gz) | Baseline absence capture; native 28-pass functional run; three current-parent actual jobs; retained-file/output inventory checks; original clipped screenshots; corrected native geometry tests. Historical timeouts, wrong keyboard assumption and conditional-scroll receiver correction remain intact. | +| [Independent functional record](functional-README.md), [archive](independent-functional.tar.gz) | Original source 424298e3: four groups, 12 actual Qt dialogs, six real background-worker/evaluator jobs; path, cancellation, race, isolation, external/identity/refusal/retry and execution-time byte provenance. | +| [Original counterexamples](counterexamples-README.md), [archive](independent-counterexamples.tar.gz) | Actual clipped controls and forced height; separately proven inherited Run-enabled/Cancel-disabled worker state. Baseline source and receiver corrections are retained. | +| [Independent final record](successor-README.md), [archive](independent-successor.tar.gz) | Exact source 983641cc: both 1100×700 and 1280×960 sizes, plus actual Clear → chooser → keyboard Run → held worker → keyboard Cancel → recovery. Both groups pass; source unchanged. | +| [Root source review](root-source-review.json) | Separate final controller/dispatch/queued-input and narrow repair acceptance; no remaining source finding. | +| [Current-parent packet](current-parent-receiving.tar.gz) | Complete independent literal tree/dependency comparison, exact script and original execution output. | + +The production author inspected both final viewport images and actual held/recovered images. The final independent receiver admitted 62 imported project files unchanged. Its real cancellation reached the evaluator before output creation. The source and reviewer records distinguish actual Qt offscreen focus from operating-system behavior. + +## Platform gate + +Local execution used the existing Python 3.12.8 / PySide6 and Qt 6.11.2 Mac runtime, without installs or hardware/daemon calls. The unchanged application imports Windows named-pipe APIs before constructing MainWindow. No Windows API stub was introduced. The permanent test of actual MainWindow(auto_connect=False) at its minimum 1100×700 is explicitly skipped on Mac and must pass in the existing windows-2022 / Python3.12 hosted suite. **That supported-platform result is pending at this evidence freeze.** Hosted results are recorded on the PR with their actual checkout and tree. + +The original functional source was not rerun solely to relabel it to the newer parent. Current-main source admission and the actual hosted suite qualify that composition separately. + +## Operator behavior and limits + +See [Evaluate a predictions file in Analysis](../../design/research/EXTERNAL_EVALUATION_WORKBENCH.md). The existing gallery does not directly tab nested eval figures; the guide points to the existing Export figures action and Windows Open job folder. The gallery owner retains that work. A selected file can change before execution; the evaluator's retained input is the execution-time provenance. The bundle selector remains editable while a queued job retains its captured value. + +The evidence uses explicit synthetic sealed bundles and files. It does not assert trained-model efficacy, scientific validity, hardware capture, installed deployment, Cocoa dialogs or Mac execution of the full Windows shell. + +## Integrity + +Each archive was read back and every regular member compared with its manifest before preservation. The three independent archives are copied unchanged, not repacked. Their original SHA-256 values are retained in [qualification-manifest.json](qualification-manifest.json), along with every readable record. The current-parent archive is a separate byte-preserving transport of that later review. + +All earlier failures remain labeled as failures. Reproduction commands, source snapshots, actual inputs/results and raw logs are inside the corresponding packet. Do not substitute these records for receiving a changed source or relabel their platform. diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/author-packet-summary.json b/docs/evidence/analysis-predictions-6e5752b49b6f/author-packet-summary.json new file mode 100644 index 00000000..0fa16662 --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/author-packet-summary.json @@ -0,0 +1,92 @@ +{ + "archive": "capturesuite69-author-receiving.tar.gz", + "bytes": 3053833, + "sha256": "058140836cde48ee8940dbadaf88a620bdcad23e0e0d8439bf03af611d7f9ed1", + "git_blob": "8a392d76e8fdd8955dd29c5dbf28972e46221bf2", + "regular_members": 556, + "manifest_sha256": "c5913e1b30880ea94d0cfbbb68fdac5909ab2c604a82fba3e0fa5b9609b453a4", + "original_result_provenance_retained": true, + "source": { + "status": "accepted-source-preservation", + "source_head": "4987c99c49751461a6b53a90ce9c43e39062ad59", + "source_tree": "2d418e2094fae2caa59b17be66e1e7524ace66f0", + "parent": "3cbb5300bdc79c157370d1b3e599a6c82752d54a", + "parent_tree": "4af105896633559b35823a7aa2710dc30b2f1740", + "parent_leaves": 1057, + "source_leaves": 1060, + "unchanged_parent_leaves": 1053, + "changed": { + "desktop/capture_desktop/screen_analysis.py": { + "before": { + "mode": "100644", + "sha": "78905bbde5de536dad125b6e4e48a475e9b14272" + }, + "after": { + "mode": "100644", + "sha": "b169ab728babf879701ccbb084f959ee02ae4155" + } + }, + "desktop/capture_desktop/widgets_analysis_jobs.py": { + "before": { + "mode": "100644", + "sha": "c4d2f4513032d744f1f8066f0c887cda71d9b911" + }, + "after": { + "mode": "100644", + "sha": "8f65859da5b4bebf8e7a4b1cd9cba60432fc2972" + } + }, + "desktop/capture_desktop/widgets_analysis_predictions.py": { + "before": null, + "after": { + "mode": "100644", + "sha": "0e47254965e7da4428f9e2f0b3a2d75f1e92a433" + } + }, + "docs/design/ANALYSIS.md": { + "before": { + "mode": "100644", + "sha": "13fbc44f76c819edf38b028747fcdf8be6f39a16" + }, + "after": { + "mode": "100644", + "sha": "d24f789bbf3e2797dac019526ce042dae0a827f9" + } + }, + "docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md": { + "before": null, + "after": { + "mode": "100644", + "sha": "30f9c1c043f2a5e222d34691b7520e8262f8302f" + } + }, + "docs/design/research/PROGRESS.md": { + "before": { + "mode": "100644", + "sha": "928ec029f5a6e289c87ea32cd00b55c978b9af92" + }, + "after": { + "mode": "100644", + "sha": "7f4f3157dc01812054f6d404b39fcfc3cde29f69" + } + }, + "tests/ui/test_analysis_predictions.py": { + "before": null, + "after": { + "mode": "100644", + "sha": "94d9b13131785f88990509e622a7e504fa9047ad" + } + } + }, + "accepted_983_files": { + "desktop/capture_desktop/screen_analysis.py": "a62e7e9ae63c658205531ef51844f4a68a64c738ce1de6083f93a4e9a5ddbc94", + "desktop/capture_desktop/widgets_analysis_jobs.py": "a012ac150e1301a630417cd78238c050093206f94f073e6e0ee4c90f22b81b62", + "desktop/capture_desktop/widgets_analysis_predictions.py": "d865031e313b0c1aed23a2cb0beeb20441a96d380f0b3652458107ba4a6e2435", + "docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md": "8e619f78cfa53b4447e50389a38f89a6b01cd4ac10710cc24bdd6def3d4c6b99", + "tests/ui/test_analysis_predictions.py": "e87f63a19639d40785d922b3e78d27b8487feb51a3c3c46d89ea6184f7facbe2" + }, + "current_documentation_prefixes_preserved": true, + "windows_mainwindow_execution": "pending hosted Windows CI", + "worktree_clean": true + } +} diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/author-receiving.tar.gz b/docs/evidence/analysis-predictions-6e5752b49b6f/author-receiving.tar.gz new file mode 100644 index 00000000..8a392d76 Binary files /dev/null and b/docs/evidence/analysis-predictions-6e5752b49b6f/author-receiving.tar.gz differ diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/counterexamples-README.md b/docs/evidence/analysis-predictions-6e5752b49b6f/counterexamples-README.md new file mode 100644 index 00000000..fc6435b4 --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/counterexamples-README.md @@ -0,0 +1,35 @@ +# CaptureSuite69: original-source layout and busy-state counterexamples + +Exact source remains 424298e30d6e0f08fe4124fa282328575c40d203 / tree b69adfdf45711035963094b811760500cb76c966, based on PR65 d7459fa62dd7a57d5565214e10638e055ad5877c. No production source was changed during these observations. + +## Inherited Run/Cancel state: confirmed + +The actual QThread was reserved and running in a pass-through gate immediately before the unchanged capture_analysis.run call. The new evaluation input was disabled. Run remained enabled and Cancel remained disabled. The input was the receiver's own finalized-format fixture with an explicit external prediction path. + +The receiver invoked worker.request_cancel solely for cleanup; this was not a successful native Cancel action. After releasing the gate, the actual evaluator entry consumed cancellation before creating job outputs. The source proof removes the candidate's only additive evaluation-input busy hook and obtains the entire d7459fa screen_analysis.py byte-for-byte. This is an inherited screen-state defect. The author recorded the narrow reservation/start-guard extension at https://github.com/Jacob-Met/CaptureSuite/issues/69#issuecomment-6064049439 . + +busy-counterexample-source.json freezes the exact parent/candidate file hashes, raw receipt hash, real worker call and scope of the accepted observation. + +## Original Analysis viewport layout: confirmed + +The corrected receiver uses real global Qt positions to intersect every control rectangle with its ancestor rectangles, walks native keyboard focus, and compares rendered control/help height to the toolkit's actual minimum/word-wrapped height. It requests the actual standalone AnalysisScreen viewport sizes, without imposing a larger window after layout. + +- At 1280 x 960, the mode control receives 25px and path/Choose/Clear 24px against 29px minimum hints. Help text receives 24px for 39px of wrapped text. +- A requested 1100 x 700 viewport is forced to 1100 x 881; the new mode/file controls and help are compressed to 11px. +- Both original mode screenshots were visually inspected and confirm the clipped content. + +The corrected original-source geometry run intentionally skips the worker. The already completed original held-worker observation is not replayed. + +## Receiver correction and platform limits + +The first viewport attempt used ancestor.mapTo(descendant), which Qt rejects. All its calculated geometry is excluded. Its directly observed enabled booleans, actual QThread state, real worker call and cancellation do not depend on that coordinate helper. The original source, full diagnostic output, receipt and held-worker screenshot are retained. + +The corrected receiver uses mapToGlobal offsets and retains its failed baseline receipt. Its SHA-256 is add4a8df6d01e870b2e35653d2551bfb1b57069d20f6d894875c65faa9f8fad6. No product source changed for this receiver correction. + +These native observations use Mac Python 3.12.8 / PySide6 6.11.2 with Qt offscreen controls and explicit active-Qt-window selection. They do not qualify Cocoa dialogs or operating-system window-manager focus. Actual MainWindow cannot be imported unmodified on Mac: app.py imports the control client, which imports msvcrt and calls ctypes.WinDLL at module load. The Windows-only full shell 1100 x 700 gate therefore remains separate. No transport substitution or daemon startup is performed. + +## Reproduction and custody + +The helpers reuse the independent fixture and actual-dialog code from the accepted functional packet (archive SHA-256 8efa28046febd1fbadadefaf0cb35229e9cc4cefa6b621bd9c2d35f979c7737f). The baseline geometry run uses --geometry-only. The same corrected receiver's normal mode requires a geometrically accepted candidate to use real Clear, file chooser, Run, held-worker Cancel and settled re-enable, rather than replaying the six unchanged evaluator cases. + +native-counter-execution.json retains the exact native commands and process completions. Both original receiver files and receipts are unchanged. The complete original candidate commit bytes are retained to support reconstructing that local commit from its public base and the four candidate files in the companion functional packet. The source files in this packet remain exact Git blobs. MANIFEST.json and archive re-reading verify every member. diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/current-parent-receiving.json b/docs/evidence/analysis-predictions-6e5752b49b6f/current-parent-receiving.json new file mode 100644 index 00000000..81f062ce --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/current-parent-receiving.json @@ -0,0 +1,104 @@ +{ + "scope": "Read-only current-parent source admission; no execution replay", + "source_commit": "4987c99c49751461a6b53a90ce9c43e39062ad59", + "source_tree": "2d418e2094fae2caa59b17be66e1e7524ace66f0", + "parent": "3cbb5300bdc79c157370d1b3e599a6c82752d54a", + "parent_tree": "4af105896633559b35823a7aa2710dc30b2f1740", + "parent_git_api_inventory_exact": true, + "parent_leaves": 1057, + "source_leaves": 1060, + "unchanged_parent_leaves": 1053, + "changed_existing_paths": [ + "desktop/capture_desktop/screen_analysis.py", + "desktop/capture_desktop/widgets_analysis_jobs.py", + "docs/design/ANALYSIS.md", + "docs/design/research/PROGRESS.md" + ], + "added_paths": [ + "desktop/capture_desktop/widgets_analysis_predictions.py", + "docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md", + "tests/ui/test_analysis_predictions.py" + ], + "planned_paths_exact": [ + "desktop/capture_desktop/screen_analysis.py", + "desktop/capture_desktop/widgets_analysis_jobs.py", + "desktop/capture_desktop/widgets_analysis_predictions.py", + "docs/design/ANALYSIS.md", + "docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md", + "docs/design/research/PROGRESS.md", + "tests/ui/test_analysis_predictions.py" + ], + "accepted_983_runtime_test_guide_exact": { + "desktop/capture_desktop/widgets_analysis_predictions.py": { + "bytes": 5591, + "sha256": "d865031e313b0c1aed23a2cb0beeb20441a96d380f0b3652458107ba4a6e2435", + "git_blob": "0e47254965e7da4428f9e2f0b3a2d75f1e92a433" + }, + "desktop/capture_desktop/widgets_analysis_jobs.py": { + "bytes": 6995, + "sha256": "a012ac150e1301a630417cd78238c050093206f94f073e6e0ee4c90f22b81b62", + "git_blob": "8f65859da5b4bebf8e7a4b1cd9cba60432fc2972" + }, + "desktop/capture_desktop/screen_analysis.py": { + "bytes": 17960, + "sha256": "a62e7e9ae63c658205531ef51844f4a68a64c738ce1de6083f93a4e9a5ddbc94", + "git_blob": "b169ab728babf879701ccbb084f959ee02ae4155" + }, + "tests/ui/test_analysis_predictions.py": { + "bytes": 20960, + "sha256": "e87f63a19639d40785d922b3e78d27b8487feb51a3c3c46d89ea6184f7facbe2", + "git_blob": "94d9b13131785f88990509e622a7e504fa9047ad" + }, + "docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md": { + "bytes": 4655, + "sha256": "8e619f78cfa53b4447e50389a38f89a6b01cd4ac10710cc24bdd6def3d4c6b99", + "git_blob": "30f9c1c043f2a5e222d34691b7520e8262f8302f" + } + }, + "current_parent_document_prefixes_preserved": true, + "document_additions": { + "docs/design/ANALYSIS.md": "\n### Native workbench: supplied prediction files\n\nIn the Analysis tab, select **Eval report**, the source **ML bundle job**, and\n**External predictions file** under **Evaluation input**. **Choose…** selects\nthe JSON file; **Run** sends that explicit choice through the existing job\nworker and evaluator. Empty or unavailable external choices block the run and\nnever select the identity simulation. **Identity teacher (simulation)** remains\nthe explicit default for fixture checks.\n\nThe control column scrolls at smaller window sizes. The workbench preserves a\nchoice after dialog cancellation and clears it when a different session is\nopened. A running job captures its request, disables the evaluation-input controls, and offers the\nexisting cancellation action. The evaluator retains the bytes it receives when\nthe job executes; selecting a file does not freeze its contents.\n\nSee [Evaluate a predictions file in Analysis](research/EXTERNAL_EVALUATION_WORKBENCH.md)\nfor the operator flow, file-choice behavior and retained report/figure paths.\n", + "docs/design/research/PROGRESS.md": "\n## 2026-10-08 — Native external-prediction evaluation (#69; HAMON 6e5752b49b6f)\n\nThe native Analysis workbench now exposes the existing #57/PR65 evaluator with\nan explicit **Evaluation input** choice. **Identity teacher (simulation)**\nretains the original default. **External predictions file** offers a native\nfile chooser, literal read-only path, Clear and actionable missing-file errors.\nIt sends the ordinary prediction_path job extra without replacing evaluator\nadmission, source binding, scoring, schema or publication. Source and raw files\nremain read-only. See [the operator guide](EXTERNAL_EVALUATION_WORKBENCH.md).\n\nChooser cancellation preserves the current choice. A new session clears the\nfile; command/mode changes preserve it within the same session. Revision checks\nretire file dialogs whose context changed while they were open. The worker\ncaptures scalar request values, while the evaluator admits and retains the\nactual input bytes when it runs. No file-byte snapshot or trained-model\nexecution is implied by a selection.\n\nNative receiving exposed two separate workbench findings. The first candidate\nclipped its new controls under vertical pressure, so the unchanged control\ncolumn now lives in a native scroll area. Keyboard controls, wrapped text and\nRun/Cancel remain reachable without forcing a taller Analysis window. An\nindependent held-worker check also proved the parent's Run/Cancel state used\nisRunning before thread.start and never resynchronized. The shared screen hunk\nnow treats a reserved worker as busy and rejects any duplicate start while it\nis reserved; the existing worker/cancellation/job lifecycle is unchanged.\n\nThe original absence capture, functional source, clipped images, inherited busy\ncounterexample and receiver-only corrections remain distinct in qualification.\nLocal Python 3.12.8 / PySide6 6.11.2 receiving uses actual Qt dialogs and QThreads,\nreal external/identity/refused jobs, retained-file and output-inventory checks,\nand keyboard reachability at 1100×700 and 1280×960. The actual MainWindow imports\nWindows named-pipe APIs before its auto_connect=False guard, so its minimum-size\ncase is a supported Windows CI gate, explicitly skipped on Mac. No Windows\nhardware, OS-native dialog, model authorship or scientific efficacy claim is\nmade from the local fixture evidence. Independent receiving and hosted results\nare recorded separately with their exact source identities.\n" + }, + "unowned_imports_admitted": 66, + "changed_dependencies": [ + { + "path": "libs/python/capture_analysis/capture_analysis/windows.py", + "original_git_blob": "80bea6bd69365a48c5b204c26d054b8ae179437a", + "current_git_blob": "aaafd8db78f82a5a56178135dbed5bfd4f45c634", + "unchanged": false + }, + { + "path": "libs/python/capture_analysis/capture_analysis/jobs.py", + "original_git_blob": "ad0a551e90e5d06ec38a9218277efa24b0030c75", + "current_git_blob": "dd25dd3d6cc4b583f0fe6cfb352fba10be22352b", + "unchanged": false + } + ], + "actual_false_anchor_full_session_jobs": 6, + "dependency_admission_sha256": "a4b2840bc489a69599b6d7f5bbb98a6778801a07fd38e4f6bfd32b48a7419b00", + "unchanged_archives": [ + { + "path": "capturesuite69-functional-receiving.tar.gz", + "bytes": 743543, + "sha256": "8efa28046febd1fbadadefaf0cb35229e9cc4cefa6b621bd9c2d35f979c7737f", + "git_blob": "b0a4782f81edf51f0980526e3984a5e6871c91d5" + }, + { + "path": "capturesuite69-original-counterexamples.tar.gz", + "bytes": 552335, + "sha256": "c3050c15d7552d63ce5281e483e22b72d37bbdabd41a955135dbab4dd15b8a31", + "git_blob": "6878567567242d4f97a9f88ef4cc55cab6d36266" + }, + { + "path": "capturesuite69-successor-receiving.tar.gz", + "bytes": 1504813, + "sha256": "9327437bb919a80e7550df14f9b1ba950cb8b806a1e4c1c7cd764281130abca3", + "git_blob": "68b4e67d992bc7836a78dd2e9a1ea8b7c35dcbd9" + } + ], + "script_sha256": "4a1e1a5ac0e9341e1069db7c214af726c7f2a9ac1b6c7c787aabd5a7ed7750ce", + "actual_accepted_source_execution": "983641cc270db34be0711d554cd2c82608cb0b8c", + "actual_windows_full_shell_execution": "pending hosted CI", + "passed": true +} diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/current-parent-receiving.tar.gz b/docs/evidence/analysis-predictions-6e5752b49b6f/current-parent-receiving.tar.gz new file mode 100644 index 00000000..b2d05771 Binary files /dev/null and b/docs/evidence/analysis-predictions-6e5752b49b6f/current-parent-receiving.tar.gz differ diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/functional-README.md b/docs/evidence/analysis-predictions-6e5752b49b6f/functional-README.md new file mode 100644 index 00000000..741542f7 --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/functional-README.md @@ -0,0 +1,48 @@ +# CaptureSuite69 independent native functional receiving + +## Exact scope and result + +Accepted functional source: `424298e30d6e0f08fe4124fa282328575c40d203`, tree `b69adfdf45711035963094b811760500cb76c966`, parent PR65 `d7459fa62dd7a57d5565214e10638e055ad5877c`. + +The independent receiver ran on the existing Mac Python 3.12.8 / PySide6 6.11.2 runtime with Qt's offscreen platform. Attempt r4 passes four groups, twelve real Qt file dialogs, and six actual background-worker/evaluator jobs. All 69 imported project files and the four candidate paths were hash-admitted against the frozen Git tree and unchanged after execution. The final process exited zero in 16.02 seconds. + +The receiver owns its synthetic finalized-format packages and supplied predictions. It does not import production tests or the author's fixtures. Its windows have independently chosen order, nonzero prediction offsets, exact int64 timestamps above 2^53, Unicode and significant filename whitespace. These are controlled fixture results, not a measured accuracy or sensor-capture claim. + +## What the gate establishes + +1. Actual QFileDialog choices preserve the returned path, including Unicode and a trailing native POSIX filename space. Cancellation retains the previous choice. Late real-dialog results are retired after explicit controller changes to mode, command, busy reservation, or package identity. Same-package refresh retains the path; a different package clears it while preserving external mode. Other commands omit prediction_path and retain their existing dependency/window values. Clear preserves external mode and gives the Choose control Qt focus. + +2. Real workbench external jobs retain the exact selected bytes, source-bundle manifest and SHA-256 provenance. Predictions are aligned to the fixture's canonical source-window order without losing its integer timestamps or supplied values. Identity mode omits a previously retained path and produces the existing identity-teacher report. A source-hash mismatch produces the actual visible error and retained failed-job record, then choosing a valid second file allows a real successful retry. + +3. A pass-through run observer pauses one real worker immediately before calling the unchanged evaluator. The already queued extra dictionary keeps its original dependency and path when the old dependency picker is changed through its editable Qt control. The new input widget is disabled both during reservation before thread start and during work. A deliberate receiver-authored replacement of the selected file at the same path is consumed and retained at execution. This qualifies path selection and execution-time byte binding; it does not claim that selecting a file freezes its bytes. + +4. External mode with an empty path is blocked by an actionable native UI message. Deliberately bypassing that frontend validation and invoking the actual worker with the exact explicit-empty extra still fails as external input. It does not fall back to identity output. + +The six jobs comprise three completed external runs, one completed identity run, one refused source binding, and one refused empty external path. Positive jobs retain their actual completed_with_warnings status and provisional fixture warnings. Every real evaluator call is forwarded once to the unchanged function; no scorer, worker, dialog result, output or error is stubbed. + +## Receiver corrections preserved + +- r1: the receiver called QFileDialog.selectFile before its requested file became selected; selectedFiles was empty. No job ran. The receiver now clicks the actual filesystem view and verifies the native selection before accepting. +- r2: eight actual dialog challenges succeeded, then an immediate application-wide focus assertion lacked an active-window precondition after nested dialogs and hidden/shown controls. No job ran. +- r3: requesting window activation through the offscreen platform did not establish that precondition. No job ran. +- r4: the receiver explicitly selects the active Qt window before delivering keyboard events. It still checks the post-Clear focus outcome. The deprecated QApplication.setActiveWindow diagnostic is retained in the actual log. + +All original failing receipts, exact receiver sources and native process transcripts are retained. No production source changed to resolve these receiving setup errors. + +## UI and source limits + +The two original screenshots were visually inspected. The workbench screenshot is 1540 × 1100; this functional packet does not accept the separately observed clipping at 1280 × 960 or the real shell minimum 1100 × 700. The author is repairing that layout in an isolated successor, which requires its own focused receiving. + +Dialogs and focus here are actual Qt controls on the offscreen platform, with AA_DontUseNativeDialogs and explicit Qt application activation. This is not a Cocoa/Windows system-dialog or window-manager focus qualification. + +The source snapshot retains imported project modules, the four candidate files, relevant root metadata and the kinematics registry. The packet is evidence, not a replacement full checkout. To reproduce, use the exact complete candidate Git checkout, the existing compatible project dependencies, and run: + +```sh +python -B receiver/receive-native-evaluation.py --source /path/to/exact/checkout --output /new/isolated/output +``` + +The receiver requires a new output directory and creates all application, cache, temporary and fixture state there. It makes no provider calls, daemon requests, hardware captures, dependency installations, or production-source writes. The selected runtime and native invocation are preserved in native-execution.json. + +## Packet integrity + +freeze-functional.py independently rereads every actual job output inventory, source blob, retained external byte stream, fixture-protected file, and receiver digest before archiving. Each archive member is reread and checked against MANIFEST.json. The full source tree inventory and the execution-time kinematics-registry hash are retained. Source, failed attempts, actual inputs/outputs and screenshots remain separately identifiable. diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/independent-counterexamples.tar.gz b/docs/evidence/analysis-predictions-6e5752b49b6f/independent-counterexamples.tar.gz new file mode 100644 index 00000000..68785675 Binary files /dev/null and b/docs/evidence/analysis-predictions-6e5752b49b6f/independent-counterexamples.tar.gz differ diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/independent-functional.tar.gz b/docs/evidence/analysis-predictions-6e5752b49b6f/independent-functional.tar.gz new file mode 100644 index 00000000..b0a4782f Binary files /dev/null and b/docs/evidence/analysis-predictions-6e5752b49b6f/independent-functional.tar.gz differ diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/independent-successor.tar.gz b/docs/evidence/analysis-predictions-6e5752b49b6f/independent-successor.tar.gz new file mode 100644 index 00000000..68b4e67d Binary files /dev/null and b/docs/evidence/analysis-predictions-6e5752b49b6f/independent-successor.tar.gz differ diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/qualification-manifest.json b/docs/evidence/analysis-predictions-6e5752b49b6f/qualification-manifest.json new file mode 100644 index 00000000..500cc028 --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/qualification-manifest.json @@ -0,0 +1,79 @@ +{ + "format": "capturesuite69-qualification/1", + "source_head": "4987c99c49751461a6b53a90ce9c43e39062ad59", + "source_tree": "2d418e2094fae2caa59b17be66e1e7524ace66f0", + "parent": "3cbb5300bdc79c157370d1b3e599a6c82752d54a", + "hosted_windows_mainwindow": "pending", + "files": { + "AUTHOR_RECEIVING.md": { + "bytes": 9722, + "sha256": "1e993180773916cd48dfffe3df8750c0f0367c0e0a349f109071b22ce540cfd9", + "git_blob": "e7ee796a81903b0deb75a6f1aec73db7e0c22d9e" + }, + "README.md": { + "bytes": 6298, + "sha256": "897b196fe6704adc015058d6dd1de8533c119bf325d36f52a551bac162bbb6fe", + "git_blob": "c8796672a205e3c91e85a2cc5160ce0290156dea" + }, + "author-packet-summary.json": { + "bytes": 3413, + "sha256": "41033f1b28aa4c81d4f5f4e2c194a691683f8472492877370b1572758868e56d", + "git_blob": "0fa166620a0533de017692652a70fe07d4468a26" + }, + "author-receiving.tar.gz": { + "bytes": 3053833, + "sha256": "058140836cde48ee8940dbadaf88a620bdcad23e0e0d8439bf03af611d7f9ed1", + "git_blob": "8a392d76e8fdd8955dd29c5dbf28972e46221bf2" + }, + "counterexamples-README.md": { + "bytes": 4333, + "sha256": "a3312fa751ee5589ff5944f73f5fc88f83d634fd2c30cb6d7d92e86a7be2088f", + "git_blob": "fc6435b430f0c7d7ff8dd0dd85d8ad6c5f12582f" + }, + "current-parent-receiving.json": { + "bytes": 7854, + "sha256": "2017874fe36ff7fba0644f90b1618e4e09b63a9853a97c12bc91c334b5e5618b", + "git_blob": "81f062ce958107035f1b82583feb8f569d1b39dd" + }, + "current-parent-receiving.tar.gz": { + "bytes": 61559, + "sha256": "035f7d53eb18f9666fbef419d024ad633db84479f4586bc6aab5d7602dea4913", + "git_blob": "b2d05771fe0081b27087649ba1dcbf67fd12e49e" + }, + "functional-README.md": { + "bytes": 6137, + "sha256": "cd4a9048973c01d07d6b7b0b94428befeb4495117c750f421ab614123219da3a", + "git_blob": "741542f748c2311c4a77c00fbf34aae856373cc8" + }, + "independent-counterexamples.tar.gz": { + "bytes": 552335, + "sha256": "c3050c15d7552d63ce5281e483e22b72d37bbdabd41a955135dbab4dd15b8a31", + "git_blob": "6878567567242d4f97a9f88ef4cc55cab6d36266" + }, + "independent-functional.tar.gz": { + "bytes": 743543, + "sha256": "8efa28046febd1fbadadefaf0cb35229e9cc4cefa6b621bd9c2d35f979c7737f", + "git_blob": "b0a4782f81edf51f0980526e3984a5e6871c91d5" + }, + "independent-successor.tar.gz": { + "bytes": 1504813, + "sha256": "9327437bb919a80e7550df14f9b1ba950cb8b806a1e4c1c7cd764281130abca3", + "git_blob": "68b4e67d992bc7836a78dd2e9a1ea8b7c35dcbd9" + }, + "root-source-review.json": { + "bytes": 4131, + "sha256": "ab0487bd1695fb6d543d39b7cb0bb6fe7a77df98ccd517be86acdae9a5329fa9", + "git_blob": "6c7714615303cf7b6859d9db5a4f63795814c879" + }, + "source-preservation.json": { + "bytes": 2858, + "sha256": "b53150339570298fc5df649e313b2fe5014bf8f0c4b8894314c7b35913415fd5", + "git_blob": "c8d01cc63cc996fbfb38d4284c9811ae3de47493" + }, + "successor-README.md": { + "bytes": 10233, + "sha256": "39cfa097942926516549b29ac327ccaac914cd18aff7aa7656084bdf443d549a", + "git_blob": "1d1ae20328b6a2ceeb444591d44e9355fc54cfab" + } + } +} diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/root-source-review.json b/docs/evidence/analysis-predictions-6e5752b49b6f/root-source-review.json new file mode 100644 index 00000000..6c771461 --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/root-source-review.json @@ -0,0 +1,35 @@ +{ + "schema": "hamon.capturesuite.prediction-input.root-source-review.v1", + "reviewer": "estate-6e5752b49b6f/root", + "issue": 69, + "decision": "Source accepted; native peer changed-contract receiving passed; final current-main and supported-Windows gates remain separate", + "original_native_source": "424298e30d6e0f08fe4124fa282328575c40d203", + "original_parent": "d7459fa62dd7a57d5565214e10638e055ad5877c", + "accepted_source": "983641cc270db34be0711d554cd2c82608cb0b8c", + "accepted_tree": "4cceca0d9ce05a354584c7a2e3092e5e59e6c7c9", + "production_sha256": { + "desktop/capture_desktop/widgets_analysis_predictions.py": "d865031e313b0c1aed23a2cb0beeb20441a96d380f0b3652458107ba4a6e2435", + "desktop/capture_desktop/widgets_analysis_jobs.py": "a012ac150e1301a630417cd78238c050093206f94f073e6e0ee4c90f22b81b62", + "desktop/capture_desktop/screen_analysis.py": "a62e7e9ae63c658205531ef51844f4a68a64c738ce1de6083f93a4e9a5ddbc94" + }, + "source_review": [ + "Identity mode omits prediction_path; external mode retains its key even with an empty choice. Validation refuses absent/unavailable files without falling through to identity.", + "Native choice remains literal, including significant whitespace. Cancellation preserves the prior choice. Package changes clear it; revision changes retire delayed choices after package/command/mode/busy changes.", + "AnalysisJobExtras contributes the new key only to Eval. Existing _AnalysisWorker copies the scalar extra dictionary immediately, so later UI changes cannot rewrite queued parameters.", + "The new scroll container preserves existing widgets and their order, permits natural control heights, and keeps its minimum width compatible with the existing column.", + "The common busy predicate now uses reserved thread presence. This disables Run/configuration, enables Cancel before start, and remains active until existing thread-finished cleanup releases the reservation. The matching launch guard refuses another reserved job." + ], + "demonstrated_corrections": { + "new_layout_defect": "Original native controls clipped; a forced group height overlapped Run. Root identified the actual 1100 by 700 application minimum and lack of a column scroll container. Peer baseline also observed forced 1100 by 881 and 11-pixel controls. The scroll wrapper addresses that measured constraint.", + "inherited_busy_defect": "Peer observed a real reserved/running QThread with Run enabled and Cancel disabled on source 424. Removing only the new input busy hook recovers the d745 parent screen byte-for-byte. This ancestry distinguishes the inherited defect from the feature.", + "busy_counterexample_source_receipt_sha256": "ab9b56c9f25b39b717f3c97ef1929a596a08179112fcfb21fcfea5c36f2f7880" + }, + "peer_receiving": { + "original_functional": "Four groups, twelve actual Qt dialogs and six real evaluator jobs on exact 424 source; no evaluator requalification claimed by this source review.", + "final_changed_contract": "Exact 983 source: actual AnalysisScreen at 1280 by 960 and 1100 by 700; actual chooser/Run, one real held worker, keyboard Cancel, then settled and re-enabled controls. Process 31156 completed exit 0 in the peer's recorded 12.57 seconds.", + "source_changes_by_receiver": false, + "corrections_retained": "Invalid ancestor-map coordinates excluded; forward-Tab assumptions around existing QTextEdit controls corrected to observed Shift+Tab navigation. Original receiver failures retained without product rewrites." + }, + "platform_boundary": "Actual app.py/MainWindow imports Windows msvcrt and ctypes.WinDLL(kernel32) before auto_connect=False. Mac evidence is actual AnalysisScreen and Qt viewport evidence, not a full MainWindow claim. The added Windows-only test must pass on supported hosted Windows before final integration.", + "scope": "No changes to evaluator, numerical admission/scoring, JobParams, worker implementation, output schema, dependencies, workflows, capture sources or other owners' code beyond the explicit three UI paths. Current-main imported-source equality and final full tree remain publication gates." +} diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/source-preservation.json b/docs/evidence/analysis-predictions-6e5752b49b6f/source-preservation.json new file mode 100644 index 00000000..c8d01cc6 --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/source-preservation.json @@ -0,0 +1,83 @@ +{ + "status": "accepted-source-preservation", + "source_head": "4987c99c49751461a6b53a90ce9c43e39062ad59", + "source_tree": "2d418e2094fae2caa59b17be66e1e7524ace66f0", + "parent": "3cbb5300bdc79c157370d1b3e599a6c82752d54a", + "parent_tree": "4af105896633559b35823a7aa2710dc30b2f1740", + "parent_leaves": 1057, + "source_leaves": 1060, + "unchanged_parent_leaves": 1053, + "changed": { + "desktop/capture_desktop/screen_analysis.py": { + "before": { + "mode": "100644", + "sha": "78905bbde5de536dad125b6e4e48a475e9b14272" + }, + "after": { + "mode": "100644", + "sha": "b169ab728babf879701ccbb084f959ee02ae4155" + } + }, + "desktop/capture_desktop/widgets_analysis_jobs.py": { + "before": { + "mode": "100644", + "sha": "c4d2f4513032d744f1f8066f0c887cda71d9b911" + }, + "after": { + "mode": "100644", + "sha": "8f65859da5b4bebf8e7a4b1cd9cba60432fc2972" + } + }, + "desktop/capture_desktop/widgets_analysis_predictions.py": { + "before": null, + "after": { + "mode": "100644", + "sha": "0e47254965e7da4428f9e2f0b3a2d75f1e92a433" + } + }, + "docs/design/ANALYSIS.md": { + "before": { + "mode": "100644", + "sha": "13fbc44f76c819edf38b028747fcdf8be6f39a16" + }, + "after": { + "mode": "100644", + "sha": "d24f789bbf3e2797dac019526ce042dae0a827f9" + } + }, + "docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md": { + "before": null, + "after": { + "mode": "100644", + "sha": "30f9c1c043f2a5e222d34691b7520e8262f8302f" + } + }, + "docs/design/research/PROGRESS.md": { + "before": { + "mode": "100644", + "sha": "928ec029f5a6e289c87ea32cd00b55c978b9af92" + }, + "after": { + "mode": "100644", + "sha": "7f4f3157dc01812054f6d404b39fcfc3cde29f69" + } + }, + "tests/ui/test_analysis_predictions.py": { + "before": null, + "after": { + "mode": "100644", + "sha": "94d9b13131785f88990509e622a7e504fa9047ad" + } + } + }, + "accepted_983_files": { + "desktop/capture_desktop/screen_analysis.py": "a62e7e9ae63c658205531ef51844f4a68a64c738ce1de6083f93a4e9a5ddbc94", + "desktop/capture_desktop/widgets_analysis_jobs.py": "a012ac150e1301a630417cd78238c050093206f94f073e6e0ee4c90f22b81b62", + "desktop/capture_desktop/widgets_analysis_predictions.py": "d865031e313b0c1aed23a2cb0beeb20441a96d380f0b3652458107ba4a6e2435", + "docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md": "8e619f78cfa53b4447e50389a38f89a6b01cd4ac10710cc24bdd6def3d4c6b99", + "tests/ui/test_analysis_predictions.py": "e87f63a19639d40785d922b3e78d27b8487feb51a3c3c46d89ea6184f7facbe2" + }, + "current_documentation_prefixes_preserved": true, + "windows_mainwindow_execution": "pending hosted Windows CI", + "worktree_clean": true +} diff --git a/docs/evidence/analysis-predictions-6e5752b49b6f/successor-README.md b/docs/evidence/analysis-predictions-6e5752b49b6f/successor-README.md new file mode 100644 index 00000000..1d1ae203 --- /dev/null +++ b/docs/evidence/analysis-predictions-6e5752b49b6f/successor-README.md @@ -0,0 +1,75 @@ +# CaptureSuite69: independent native layout and cancellation receiving + +## Acceptance + +The corrected author source at **983641cc270db34be0711d554cd2c82608cb0b8c**, tree **4cceca0d9ce05a354584c7a2e3092e5e59e6c7c9**, is accepted for the scoped native AnalysisScreen layout and cancellation contract. Its three runtime files remained unchanged throughout this receiving. + +The final receiver is `r6/receive-analysis-viewport-r6.py`, SHA-256 **8c2a8f172f7089440b10f17f386cae30f5966e65004129e1f830dc367fd3b2f9**. Its actual native process exited **0** after **12.57 seconds**. Both receiving groups pass, comprising the two viewport sizes and one real held-worker cancellation. The actual final receipt is `r6/receiving.json`; complete unedited tool launch/completion output is in `native-successor-execution.json`. + +This supplements the earlier six-job functional acceptance at source 424298e3. Those results retain their original source provenance. No evaluator case was rerun to manufacture successor provenance. + +## Exact source and the bounded change + +The corrected source descends from 424298e30d6e0f08fe4124fa282328575c40d203 through 4e8075a38cd31529534fe6199a7fa6a0b616d035. The new screen source wraps the existing controls column in a native QScrollArea, reuses the reserved-thread presence as the busy value, and refuses another start while that reservation exists. The prediction widget and extras module are byte-identical to the accepted functional source: + +| Source | SHA-256 | +| --- | --- | +| desktop/capture_desktop/widgets_analysis_predictions.py | d865031e313b0c1aed23a2cb0beeb20441a96d380f0b3652458107ba4a6e2435 | +| desktop/capture_desktop/widgets_analysis_jobs.py | a012ac150e1301a630417cd78238c050093206f94f073e6e0ee4c90f22b81b62 | +| desktop/capture_desktop/screen_analysis.py | a62e7e9ae63c658205531ef51844f4a68a64c738ce1de6083f93a4e9a5ddbc94 | +| tests/ui/test_analysis_predictions.py | e87f63a19639d40785d922b3e78d27b8487feb51a3c3c46d89ea6184f7facbe2 | + +`source/` preserves these four files, the added `docs/design/research/EXTERNAL_EVALUATION_WORKBENCH.md` usage guide, and the unchanged application-shell and Windows transport modules. `source-manifest.json`, the complete Git tree inventory, raw Git commit bodies, and `source-delta-from-424.patch` bind the files and ancestry. The receiver admitted all **62 actually imported project files** against the exact source tree after execution and verified a clean source worktree. No peer checkout, application configuration outside the receiver lane, or production source was modified. + +## What actually ran + +The native environment was Python **3.12.8**, PySide6 / Qt **6.11.2**, on the connected Mac. Each receiver used private application, temporary and Matplotlib directories and the existing installed runtime. No package installation or hardware/daemon call occurred. + +At requested **1280 × 960** and **1100 × 700**, the actual AnalysisScreen retained exactly those dimensions. The native keyboard walk reached the mode selector, read-only file path, Choose, Clear and Run. At each reached control, the oracle intersected its rectangle with every ancestor viewport and required its native minimum height; Cancel beside Run was visible, and all wrapped help lines had their required height. All these checks passed. Both size images and the final held/recovered images were visually inspected. + +The final user-action sequence was: + +1. Shift+Tab from Run to Clear, then Space. The exact external path cleared while external mode remained selected, and focus returned to Choose. +2. Open the actual Qt QFileDialog, select the receiver's real prediction file through its file view, and accept it. The significant spaces and Unicode in its path were preserved. +3. Tab to Run, then Space. The real worker entered an explicitly held pass-through observer on a background thread. +4. Observe Run disabled, Cancel enabled and the evaluation input disabled. The native scroll viewport kept Run and Cancel visible. +5. Shift+Tab from the job log to Cancel, then Space. The real worker's existing cancel event changed from false to true. +6. Release the scheduling hold. The unchanged evaluator consumed cancellation before creating job outputs; the worker settled, Run and evaluation input re-enabled, and Cancel disabled. Source bundles and the original package remained unchanged. + +Only the scheduling hold and an observer forwarding to the real evaluator were installed. No worker, scorer, dialog, result or error was replaced. The two pre-existing fixture bundle directories remained the complete job-directory inventory after cancellation. The actual fixture inputs and protected bundle/package bytes are preserved under `r6/`. + +## Receiver corrections are preserved separately + +The original product failures are in the unchanged companion counterexample archive listed below. This packet also retains the following successor receiver corrections without calling them production defects: + +- **R3** used the unchanged corrected geometry oracle and passed both size cases, then stopped before a chooser or job because its return-to-Clear helper assumed that repeated forward Tab would wrap through every widget. +- A separate **zero-job focus diagnostic** confirmed actual focus behavior: forward Tab from Run eventually enters the existing editable mappings QTextEdit, whose `tabChangesFocus` is false; subsequent Tab inserts text. One Shift+Tab from Run reaches visible Clear directly. +- **R5** used that backward Clear navigation and successfully selected the real file and started the real held worker. It stopped at its analogous forward-navigation assumption for Cancel: disabling Run transfers focus to the read-only job log, and continuing forward eventually enters the mappings editor. Its worker was cancelled by receiver cleanup; that is not labeled as a user Cancel action. +- **R6** changes that one navigation direction to Shift+Tab from the job log to Cancel. The recorded native focus transitions show the target was reached. The geometry assertions remain the same, and the receiver adds explicit post-cancellation assertions for Run, Cancel and evaluation-input recovery. + +The first packaging check also stopped on its strict expected-path assertion: the successor includes the added usage guide as a fifth path beyond the four-file runtime/test manifest. The corrected freezer requires exactly those five paths, includes the guide bytes and preserves the original packaging assertion and readback. That was an inventory correction, with no execution or production change. + +All four original receipts, receiver sources and raw execution outputs are retained. R3 and R5 exit 1 are preserved; the diagnostic and R6 exit 0 are preserved. There was no production-source correction during these receiver changes. Actual Qt explicit-window activation emits a recorded deprecated-API warning; it establishes the offscreen keyboard precondition and is not presented as operating-system focus behavior. + +## Platform and current-main limits + +This is actual **AnalysisScreen** receiving at the requested viewport sizes on Mac, not an execution of the full Windows MainWindow. The unchanged application shell imports Windows named-pipe APIs before construction, so it cannot be imported unmodified on this Mac. No transport stub was introduced. The author's real MainWindow(auto_connect=False) test is explicitly Windows-only; the existing CI runs all normal tests on Windows 2022 and must provide the separate supported-platform result. + +The final functional source remains pinned to 983641cc. The separately preserved current-main admission concerns **9c44354cb101c76beff79265de0040b6839d249f**, tree **50cd83ef270467490b15f74639413d238aaa15c6**. Of 66 unowned project modules in the earlier functional receipt, 65 retain their exact Git blobs. The only changed module is windows.py, confined to checkpoint_section_window's preceding-section behavior; both exact source versions are retained in `current-main-windows-source.json`. All six earlier jobs used full-session scope, with null start/end/checkpoint fields. The conclusion that this changed checkpoint helper was outside those executed branches is a source-based inference, not a replay on the newer main. Preserve the current helper when composing publication. + +## Companion evidence, unchanged + +| Archive | Bytes | SHA-256 | +| --- | ---: | --- | +| capturesuite69-functional-receiving.tar.gz | 743543 | 8efa28046febd1fbadadefaf0cb35229e9cc4cefa6b621bd9c2d35f979c7737f | +| capturesuite69-original-counterexamples.tar.gz | 552335 | c3050c15d7552d63ce5281e483e22b72d37bbdabd41a955135dbab4dd15b8a31 | + +The first archive contains the six real evaluator jobs, 12 actual file dialogs, exact retained bytes, independent scoring/provenance checks and all original receiver corrections. The second contains the original clipped geometry, inherited Run/Cancel counterexample and source-ancestry proof. This packet verifies their hashes without repacking or relabeling them. + +## Reproduction and artifact verification + +Use a source checkout matching the exact source tree and the existing project Python dependencies. The public base is d7459fa62dd7a57d5565214e10638e055ad5877c; replacing the four owned runtime/test paths and adding the usage guide with this packet's source yields the recorded successor tree. Raw ancestor commit bodies and the preceding companion source packet preserve the local receiving history. The receiver intentionally rejects another Git HEAD/tree. + +The exact native command is retained in the execution record. In another equivalent checkout, set its source path, a fresh output directory, and the recorded expected commit/tree when running the receiver. It creates its own fixtures and private application state. It never starts the Windows transport. Do not point it at a shared writable output directory. + +`MANIFEST.json` enumerates every packet member except itself. The freezer reopens the completed gzip tar, checks the exact member set, rejects non-file entries, and hashes every extracted byte against the manifest. Archive identity and manifest/receipt hashes are in the adjacent packet summary. Receivers and source files carry the project GPL-3.0-only notice where applicable. diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/FINAL_WINDOWS_SOURCE_CORRECTION.md b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/FINAL_WINDOWS_SOURCE_CORRECTION.md new file mode 100644 index 00000000..f74877f7 --- /dev/null +++ b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/FINAL_WINDOWS_SOURCE_CORRECTION.md @@ -0,0 +1,17 @@ +# Windows receiving correction and qualified source composition + +This record preserves failures as failures. Final ordinary Windows acceptance remains pending for the successor containing this record. + +The e3cab7a612cecf5c049c0e60ddc8679726978fb1 focused checkout passed 57 cases and failed one actual chooser case in 17.93 seconds (run 37831595597, job 113498048445). The actual minimum MainWindow, the narrowly scoped native Space activation and application-shortcut countercontrol, real missing-file unwind, all three evaluation jobs, and CI workflow contract passed. This is a focused result, not full-suite acceptance. + +A read-only one-node probe at the same source (run 37832352721, job 113500634388) failed to serialize Qt sidebar QUrl values. Its callback errors and bounded test failure are retained; its directory observations are not qualification evidence. Correcting only observer serialization produced run 37833005840, job 113502868940. At 2.454 and 4.454 seconds, os.listdir contained the literal existing filename ` first prediction .json`, while the actual Qt 6.12 Windows QFileSystemModel exposed `first prediction .json` in both DisplayRole and FilePathRole. The visible tree view and hidden list view agreed. The strict test driver never accepted that different path and rejected the dialog at its five-second bound. + +The successful real-chooser/cancel/reselection case therefore uses an existing Unicode filename with internal and pre-extension spaces (`first prediction café .json`). A separate actual Qt case preserves the original leading-space fixture and asserts exactly one of two outcomes: exact same-file selection and unchanged basename, or bounded refusal with the previously admitted path unchanged. The observed branch is recorded as the JUnit property `literal_file_choice`. A future Qt model fix is allowed to take the exact-selection branch. No production path is normalized; no renamed file is accepted. The original Mac significant-space evidence and original failed Windows records remain unchanged. These tests force the Qt widget dialog and make no OS-native Windows dialog claim. + +The application correction is confined to AnalysisScreen's ShortcutOverride for unmodified Space when an actual descendant QPushButton owns focus. Native KeyPress/KeyRelease still activate the button. The focused Windows gate proves unrelated C/Ctrl+Space and nonbutton Space still reach real ApplicationShortcut controls. The full current-main source picker is preserved. + +The read-only ordinary JUnit observer remains one explicit Python command after the unchanged 300-second full-suite runner. It prints actual XML testcase elements and the complete XML SHA, verifies receipt/source continuity, requires the named non-skipped MainWindow/chooser/unwind/shortcut/property cases, and checks the literal branch is explicitly recorded. Eight in-memory assertion fixtures verify exact-selection and bounded-refusal acceptance and reject skipped/duplicate MainWindow, missing countercontrol, changed XML hash and absent/invalid branch properties. This preflight is not native execution. + +Only `tests/ui/test_analysis_scope.py` is adopted from estate-7879c2abc07f's independently qualified PR79 head 7f5548fb12e040f45d7e1f5867d511dc33a618ac: exact Git blob 817412fe4352c527ad7da8406269ac67cede1709. Removing its two one-millisecond Python yields and one comment restores the exact e3/current-main preimage 2ee653a05e27e8586e74439d2bdc9eccf06505ef. Existing 120-second completion bound and output/provenance assertions remain. The owner's normal Windows run 37821319011 / job 113462781924 checked c062c338df1a2e11e927ab9efb189c062f0ea189, passed 559 cases with six pre-existing daemon skips, and exited zero in 102.7499045 seconds. Its JUnit SHA is d154115183f9d92751a047022db5c4f6e027ba12b8e4c64249f003572ff6ded8. Ownership-preserving handoffs are issue53 comment6067513793, PR79 comment6067559494 and PR84 comment6067573862. No other PR79 source is adopted. + +The source parent remains the declared current picker main 6ef303bbcf019d32288723a7741b99ce36003fd5, composed through e3. Original native archives, first Windows timeout, second ordinary timeout and both full-order diagnostics remain byte-identical. The additional original focused/probe logs are concatenated gzip streams; Python gzip.decompress restores their complete UTF-8 bytes. diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/README.md b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/README.md new file mode 100644 index 00000000..6a5b38e6 --- /dev/null +++ b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/README.md @@ -0,0 +1,29 @@ +# Windows Qt chooser receiving correction + +The original product/source qualification remains in ../analysis-predictions-6e5752b49b6f. This successor changes only the actual-Qt test helper, one regression case, the read-only hosted JUnit observation, and scoped documentation/evidence. Production widgets, screen, evaluator and job lifecycle are unchanged. + +## Original ordinary gate + +PR84 head 1f0d19c17e760d4dd5c1146ce46075cc3f3ce45d, tree 66ef9a19ffc8ab920e1be9c1d278ae0741d09af2 was tested at generated merge c185ea19d48d38e3fdbd2c7e6c53df3c5910f0f8. + +Windows run 37816542567, job 113446512304 reached the unchanged 300-second subprocess timeout. It produced no JUnit, so no MainWindow pass was claimed. The quiet log contained 522 completed markers; they did not alone establish the stopped node. The unchanged independent first-failure packet is retained as first-windows-failure.tar.gz (38,509 bytes, SHA256 0ba5ec79f69649889c146a2287adebf73e3213a9097c25e3350a7fd3f6721030). Its README distinguishes the original observation, then-unconfirmed chooser hypothesis and unavailable artifact bytes. Both supported download paths returned 403; no ZIP/member contents were received. + +## Unchanged-source diagnosis + +Diagnostic branch diagnostic/capture84-windows-6e5752b49b6f, commit 0fdcf87805b5afa2b662facdaba8e3165e1dc8e0, checks out the exact c185 source and repeats the full ordinary test order with -vv/-s, read-only observer and a stricter 150-second diagnostic bound. The workflow is preserved here as diagnostic-workflow.yml, not installed as a production workflow. + +Actual run 37820661674 / job 113460546664 collected 582 items and localized the stall to tests/ui/test_analysis_predictions.py::test_actual_qt_file_choice_cancel_and_reselection. The finish callback ran with AA_DontUseNativeDialogs true. QFileDialog's filename field and selectedFiles had dropped the fixture's leading space: "first prediction .json" instead of " first prediction .json". A nested QMessageBox titled "Choose external predictions JSON" remained active. The actual 20-second faulthandler stack points to dialog.accept() at test line 73, beneath the unchanged production getOpenFileName call. The observer and source receipt confirm the original test/production bytes stayed unchanged. Qt timers and the non-native flag worked; this is a receiver input/cleanup defect, not evidence of a production dialog defect. + +diagnostic-job-113460546664.log.gz preserves the complete raw connector log, without filtering, as concatenated gzip members: +- uncompressed: 485,096 UTF-8 bytes, SHA256 aaf93fba152b71b34a97f3e4601ede8255fcab858916f389d46ea543e8bf9658 +- compressed: 60,527 bytes, SHA256 a00458f7e581ca56f1574b1ed5e15b0e10d8ffc8f632c960515e6c9de0ddbc06 + +Python gzip.decompress or gzip.open reads every member. The raw diagnostic is a failed localization run, never a successful ordinary gate. + +## Bounded correction and required execution + +The helper still opens the real Qt file dialog. It enters the filename using Qt's quoted literal-name field syntax and asserts the exact selected path before accepting, retaining the original significant-space fixture. A separate watchdog timer rejects unexpected nested QMessageBox dialogs before rejecting the file chooser; timeout or selection failures become test assertions. An actual nonexistent-file regression challenges that cleanup and verifies the previous path survives. + +The ordinary 300-second runner, test selection, source snapshot and evidence upload remain unchanged. A read-only post-Pytest step verifies the ordinary receipt/XML identity, emits every prediction-module testcase with its full XML element and complete XML hash, and requires one passing non-skipped MainWindow, literal-file selection and bounded error-unwind case. It does not rerun any test or transform test results. + +The corrected module and embedded observer pass Python syntax admission. The previously used Mac was offline during this correction, and cloud Python has no PySide6; no new local native pass is claimed. Supported Windows execution remains required on the actual new checkout. The earlier Mac functional/layout/cancellation packets retain their original pins and remain unchanged. diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/SECOND_WINDOWS_DIAGNOSIS.md b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/SECOND_WINDOWS_DIAGNOSIS.md new file mode 100644 index 00000000..06d8c390 --- /dev/null +++ b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/SECOND_WINDOWS_DIAGNOSIS.md @@ -0,0 +1,30 @@ +# Second Windows failure and exact-source diagnosis + +The previous Windows correction remains immutable at 2db743a2cc16bb4c3e996235b4ee7b5f1c94b38b (tree f4543bbb648b74f81ded4f9c0454b171b9a371a6). No passing ordinary gate is claimed for it. + +Ordinary run37826850947 / Python job113481790199 tested exact merge e548c4fc1d86e5762deb27fe1e82d6fd494ddf7b. The unchanged300-second process limit expired again before JUnit. Source stayed clean/unchanged,1,092 files, source-map digest e2f0bbe99361474d2092122e0e3d233ac14277c3c664d7cf75a542041490a011. The C++ job113481790851 passed. Quiet progress was not used to assign passing/failing node names. + +ordinary-job-113481790199.log.gz preserves the full actual raw job log: +- raw57,081 bytes / SHA256 594d939ca33088a7870c8e41781ddbaef37abae9efab5821ad143040f21bb70b +- gzip13,120 bytes / SHA256 6b71fd5e1aae759c4c60428c0598f53484a5a8dad7726f020dbe8c714916461f + +A single follow-up diagnostic at unchanged e548 used the same full selection/order and stricter150-second limit. Diagnostic branch commit ec6d25b0b29dce785687fe2dc89a1aebf9e3b17f, actual run37828302060 / job113486762104, adds immediate read-only failure reports and extends modal observation to the existing UI tests. diagnostic-workflow-r2.yml is preserved only as evidence, not installed as an ordinary workflow. + +Its actual emitted node/phase evidence establishes: +1. test_each_native_ci_check_fails_its_own_step rejects the new inline observation block:41 commands violate the existing one-command-per-step contract. +2. test_actual_qt_file_choice_cancel_and_reselection fails at its first file. Even quoted text routes through a model path lookup that drops the significant leading space. The new exact selectedFiles guard reports this failure promptly. +3. The actual missing-file cleanup case passes, as do all three real evaluator-job tests and both standalone geometry cases. These are diagnostic call outcomes, not a passing ordinary run or standalone requalification. +4. The real MainWindow reaches1100×700 with valid layout but its Clear Space action leaves the path unchanged. The existing ApplicationShortcut Space handler captures that key before the button. The keyboard assertion is retained. +5. The later pre-existing test_selected_scope_reaches_real_thread_job_and_mcap_outputs[range] stalls. Its main thread is at QTest.qWait line129 while the real worker renders Matplotlib glyphs; read-only Qt observations show no active modal. Its cleanup also contains an unbounded polling loop. This receiving code belongs to the scope owner and is coordinated separately. + +diagnostic-job-113486762104.log.gz preserves the entire failed diagnostic: +- raw1,002,243 bytes / SHA256 38a8dbb2c584d7ffe182b18dc1c0c797f9c3d2c0af9971c15da598c19ccec235 +- gzip100,265 bytes / SHA256 59abf053d89d0f866d1ee9f1f90402e2a8925efd3e7df59c0688ca68148d756e + +Both gzip files use standard concatenated members; Python gzip.decompress/gzip.open or gunzip reads the complete original stream. + +The next candidate preserves the complete current source-picker parent6ef303bb and root's verified screen composition. Existing files are selected through literal model-row enumeration, viewport clicks and the actual dialog accept button, adapted from the unchanged independently qualified receiver786fae0f. A separate watchdog remains responsible for visible failure and modal cleanup. The product change is restricted to accepting ShortcutOverride for unmodified Space on the actual focused descendant Analysis QPushButton; native key events activate it. Real countercontrols verify other focus and keys still reach application shortcuts. No global app.py policy is changed. + +The JUnit reader is moved without weakening to tools/verify_prediction_workbench_ci.py and one executable workflow command. Its standalone synthetic admission checks accept a valid named report and refuse skipped MainWindow, duplicate MainWindow and hash-mismatched XML; those checks are explicitly synthetic, not hosted results. The original ordinary runner,300-second limit and all original consumer assertions remain. + +The full corrected composition must pass the actual ordinary Windows gate with named non-skipped MainWindow and chooser outcomes before adoption. Historical native packets and both failed ordinary/diagnostic identities remain distinct. diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/candidate-source-admission.json b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/candidate-source-admission.json new file mode 100644 index 00000000..0caaace3 --- /dev/null +++ b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/candidate-source-admission.json @@ -0,0 +1,60 @@ +{ + "historicalMain": "3cbb5300bdc79c157370d1b3e599a6c82752d54a", + "currentMain": "6ef303bbcf019d32288723a7741b99ce36003fd5", + "screenBaseBlob": "db197c9c65ae70565678f209f6ecbd72779169b5", + "screenKeyboardBlob": "83bd8e5f642fb389dcd1a5f4b5820bfba0140086", + "rootScreenInverseExact": true, + "currentProgressPrefixExact": true, + "ownProgressSuffixCharacters": 3292, + "testBlob": "3947b593dfa2e4b499268ad984a2bcc0d4856844", + "testSha256": "7f00dfb87af554e500681dd953326df85636e682269e809020f7ace5f060a61e", + "runtimeAcceptance": "pending ordinary Windows current-composition execution", + "scopeWait": "Original6ef scope test retained pending its active owner; focused source receiving only", + "sources": { + "correctedSourceScreen": { + "blob": "83bd8e5f642fb389dcd1a5f4b5820bfba0140086", + "sha256": "c3285b0fdd6c67dbe80f38cbce843f8aa3a8623f1d1e0a10b5c33d3c109a24c2" + }, + "tests": { + "blob": "3947b593dfa2e4b499268ad984a2bcc0d4856844", + "sha256": "7f00dfb87af554e500681dd953326df85636e682269e809020f7ace5f060a61e" + }, + "junitObserver": { + "blob": "09fe01c8b049c6853b2346fafff23a08d0838c8d", + "sha256": "ee579cf1ada53a65c1bde137df2cd7db42f6d633c9a7365da069c3abb9838dc4" + }, + "ci": "1067e9944ce4836d8cd1673442687333ef7ad564" + }, + "junitSyntheticReceipt": { + "synthetic_only": true, + "in_memory_file_fixtures": true, + "results": [ + { + "fixture": "valid", + "accepted": true, + "pass": true + }, + { + "fixture": "skipped-main", + "accepted": false, + "pass": true + }, + { + "fixture": "duplicate-main", + "accepted": false, + "pass": true + }, + { + "fixture": "hash-mismatch", + "accepted": false, + "pass": true + }, + { + "fixture": "missing-countercontrol", + "accepted": false, + "pass": true + } + ], + "source_sha256": "ee579cf1ada53a65c1bde137df2cd7db42f6d633c9a7365da069c3abb9838dc4" + } +} diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-job-113460546664.log.gz b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-job-113460546664.log.gz new file mode 100644 index 00000000..1dc58c04 Binary files /dev/null and b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-job-113460546664.log.gz differ diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-job-113486762104.log.gz b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-job-113486762104.log.gz new file mode 100644 index 00000000..4d666ef5 Binary files /dev/null and b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-job-113486762104.log.gz differ diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-workflow-r2.yml b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-workflow-r2.yml new file mode 100644 index 00000000..7fba7a30 --- /dev/null +++ b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-workflow-r2.yml @@ -0,0 +1,97 @@ +name: Capture84 exact-source Windows diagnosis + +on: + push: + branches: ["diagnostic/capture84-windows-6e5752b49b6f"] + +permissions: + contents: read + +jobs: + diagnose: + runs-on: windows-2022 + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + with: + ref: e548c4fc1d86e5762deb27fe1e82d6fd494ddf7b + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install existing project CI dependencies + run: python -m pip install -r requirements-ci.txt + - name: Reproduce existing generated-source preparation + run: | + python tools/gen_protos.py + python tools/gen_session_schemas.py + git diff --exit-code + - name: Observe unchanged suite with a bounded diagnostic process + shell: pwsh + env: + QT_QPA_PLATFORM: offscreen + PYTHONIOENCODING: utf-8 + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" + run: | + @' + import hashlib, json, os, pathlib, platform, subprocess, sys, time + from tools.run_ci_tests import source_snapshot + import PySide6 + root = pathlib.Path.cwd() + out = root / "build/evidence/prediction-input-windows-diagnostic" + out.mkdir(parents=True, exist_ok=False) + expected = "e548c4fc1d86e5762deb27fe1e82d6fd494ddf7b" + before = source_snapshot(root) + assert before["commit"] == expected and not before["worktree_dirty"] + (out / "source-before.json").write_text(json.dumps(before, indent=2)+"\n", encoding="utf-8") + plugin = "# SPDX-License-Identifier: GPL-3.0-only\n\"\"\"Read-only node and Qt modal observations for the isolated Windows diagnosis.\"\"\"\nimport json\nimport time\nimport sys\n\n_timers = []\n_started = time.monotonic()\n_old_profile = None\n\ndef _emit(kind, **fields):\n print(\"CAPTURE_DIAGNOSTIC \" + json.dumps(\n {\"kind\": kind, \"elapsed\": time.monotonic() - _started, **fields},\n ensure_ascii=True, sort_keys=True), flush=True)\n\ndef pytest_collection_finish(session):\n _emit(\"collection\", count=len(session.items),\n node_ids=[item.nodeid for item in session.items])\n\ndef pytest_runtest_call(item):\n _emit(\"call\", node_id=item.nodeid)\n if item.path.parent.name != \"ui\":\n return\n from PySide6.QtCore import QTimer, Qt\n from PySide6.QtWidgets import QApplication, QFileDialog, QLineEdit\n global _old_profile\n _old_profile = sys.getprofile()\n def profile(frame, event, _arg):\n if (event in (\"call\", \"return\")\n and frame.f_code.co_name == \"finish\"\n and frame.f_code.co_filename.endswith(\"test_analysis_predictions.py\")):\n fields = {\"event\": event, \"node_id\": item.nodeid,\n \"dont_use_native\": QApplication.testAttribute(\n Qt.ApplicationAttribute.AA_DontUseNativeDialogs)}\n dialog = frame.f_locals.get(\"dialog\", frame.f_locals.get(\"active\"))\n try:\n if isinstance(dialog, QFileDialog):\n fields.update(dialog_class=type(dialog).__name__,\n visible=dialog.isVisible(),\n selected_files=dialog.selectedFiles())\n except RuntimeError as exc:\n fields[\"dialog_read_error\"] = str(exc)\n _emit(\"finish-callback\", **fields)\n sys.setprofile(profile)\n app = QApplication.instance()\n if app is None:\n return\n ticks = [0]\n def observe():\n ticks[0] += 1\n active = QApplication.activeModalWidget()\n dialogs = []\n for widget in app.topLevelWidgets():\n if isinstance(widget, QFileDialog):\n filename = widget.findChild(QLineEdit, \"fileNameEdit\")\n dialogs.append({\n \"class\": type(widget).__name__,\n \"title\": widget.windowTitle(),\n \"visible\": widget.isVisible(),\n \"modal\": widget.isModal(),\n \"directory\": widget.directory().path(),\n \"selected_files\": widget.selectedFiles(),\n \"filename_edit\": None if filename is None else filename.text(),\n })\n _emit(\"qt-modal\", node_id=item.nodeid, tick=ticks[0],\n dont_use_native=QApplication.testAttribute(\n Qt.ApplicationAttribute.AA_DontUseNativeDialogs),\n active_class=None if active is None else type(active).__name__,\n active_title=None if active is None else active.windowTitle(),\n dialogs=dialogs)\n timer = QTimer(app)\n timer.timeout.connect(observe)\n timer.start(2000)\n _timers.append(timer)\n observe()\n\ndef pytest_runtest_teardown(item):\n if item.path.parent.name == \"ui\":\n sys.setprofile(_old_profile)\n while _timers:\n timer = _timers.pop()\n timer.stop()\n timer.deleteLater()\n _emit(\"teardown\", node_id=item.nodeid)\n\n\ndef pytest_runtest_logreport(report):\n _emit(\"report\", node_id=report.nodeid, phase=report.when, outcome=report.outcome)\n if report.failed:\n _emit(\"failure\", node_id=report.nodeid, phase=report.when,\n longrepr=str(report.longrepr),\n sections=[{\"name\": name, \"content\": content}\n for name, content in report.sections])\n" + (out / "capture_prediction_observer.py").write_text(plugin, encoding="utf-8", newline="\n") + env = dict(os.environ) + env["QT_QPA_PLATFORM"] = "offscreen" + env["PYTHONUNBUFFERED"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONDONTWRITEBYTECODE"] = "1" + env["PYTHONPATH"] = str(out) + os.pathsep + str(root) + env["PYTEST_PLUGINS"] = "capture_prediction_observer" + command = [sys.executable, "-u", "-B", "-m", "pytest", "tests", "-vv", "-s", + "-o", "faulthandler_timeout=20", "--ignore=tests/kill_tests", + "--junitxml="+str(out / "pytest.xml")] + started=time.monotonic() + timeout=False + try: + result=subprocess.run(command,cwd=root,env=env,stdout=subprocess.PIPE, + stderr=subprocess.STDOUT,timeout=150) + raw=result.stdout + code=result.returncode + except subprocess.TimeoutExpired as exc: + timeout=True + raw=exc.stdout or b"" + code=None + (out / "pytest.log").write_bytes(raw) + print(raw.decode("utf-8",errors="replace"),flush=True) + after=source_snapshot(root) + receipt={"kind":"diagnosis-only","source":expected, + "source_unchanged":before==after, + "source_file_count":len(before["files"]), + "source_tree_sha256":before["tree_sha256"], + "python":sys.version,"platform":platform.platform(), + "pyside":PySide6.__version__,"command":command, + "timeout_seconds":150,"timed_out":timeout,"process_exit":code, + "seconds":time.monotonic()-started, + "log_sha256":hashlib.sha256(raw).hexdigest(), + "observer_sha256":hashlib.sha256(plugin.encode()).hexdigest(), + "observer":"read-only node/Qt modal fields; no file-dialog or worker replacement", + "ordinary_ci_gate_accepted":False} + (out / "receipt.json").write_text(json.dumps(receipt,indent=2)+"\n",encoding="utf-8") + print(json.dumps(receipt,indent=2),flush=True) + sys.exit(1 if timeout or code != 0 or before != after else 0) + + '@ | python -u - + - name: Retain exact diagnostic evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: capture84-windows-diagnostic + path: build/evidence/prediction-input-windows-diagnostic/ + if-no-files-found: error + retention-days: 7 diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-workflow.yml b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-workflow.yml new file mode 100644 index 00000000..d410d368 --- /dev/null +++ b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/diagnostic-workflow.yml @@ -0,0 +1,97 @@ +name: Capture84 exact-source Windows diagnosis + +on: + push: + branches: ["diagnostic/capture84-windows-6e5752b49b6f"] + +permissions: + contents: read + +jobs: + diagnose: + runs-on: windows-2022 + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + with: + ref: c185ea19d48d38e3fdbd2c7e6c53df3c5910f0f8 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install existing project CI dependencies + run: python -m pip install -r requirements-ci.txt + - name: Reproduce existing generated-source preparation + run: | + python tools/gen_protos.py + python tools/gen_session_schemas.py + git diff --exit-code + - name: Observe unchanged suite with a bounded diagnostic process + shell: pwsh + env: + QT_QPA_PLATFORM: offscreen + PYTHONIOENCODING: utf-8 + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" + run: | + @' + import hashlib, json, os, pathlib, platform, subprocess, sys, time + from tools.run_ci_tests import source_snapshot + import PySide6 + root = pathlib.Path.cwd() + out = root / "build/evidence/prediction-input-windows-diagnostic" + out.mkdir(parents=True, exist_ok=False) + expected = "c185ea19d48d38e3fdbd2c7e6c53df3c5910f0f8" + before = source_snapshot(root) + assert before["commit"] == expected and not before["worktree_dirty"] + (out / "source-before.json").write_text(json.dumps(before, indent=2)+"\n", encoding="utf-8") + plugin = "# SPDX-License-Identifier: GPL-3.0-only\n\"\"\"Read-only node and Qt modal observations for the isolated Windows diagnosis.\"\"\"\nimport json\nimport time\nimport sys\n\n_timers = []\n_started = time.monotonic()\n_old_profile = None\n\ndef _emit(kind, **fields):\n print(\"CAPTURE_DIAGNOSTIC \" + json.dumps(\n {\"kind\": kind, \"elapsed\": time.monotonic() - _started, **fields},\n ensure_ascii=True, sort_keys=True), flush=True)\n\ndef pytest_collection_finish(session):\n _emit(\"collection\", count=len(session.items),\n node_ids=[item.nodeid for item in session.items])\n\ndef pytest_runtest_call(item):\n _emit(\"call\", node_id=item.nodeid)\n if item.path.name != \"test_analysis_predictions.py\":\n return\n from PySide6.QtCore import QTimer, Qt\n from PySide6.QtWidgets import QApplication, QFileDialog, QLineEdit\n global _old_profile\n _old_profile = sys.getprofile()\n def profile(frame, event, _arg):\n if (event in (\"call\", \"return\")\n and frame.f_code.co_name == \"finish\"\n and frame.f_code.co_filename.endswith(\"test_analysis_predictions.py\")):\n fields = {\"event\": event, \"node_id\": item.nodeid,\n \"dont_use_native\": QApplication.testAttribute(\n Qt.ApplicationAttribute.AA_DontUseNativeDialogs)}\n dialog = frame.f_locals.get(\"dialog\")\n try:\n if isinstance(dialog, QFileDialog):\n fields.update(dialog_class=type(dialog).__name__,\n visible=dialog.isVisible(),\n selected_files=dialog.selectedFiles())\n except RuntimeError as exc:\n fields[\"dialog_read_error\"] = str(exc)\n _emit(\"finish-callback\", **fields)\n sys.setprofile(profile)\n app = QApplication.instance()\n if app is None:\n return\n ticks = [0]\n def observe():\n ticks[0] += 1\n active = QApplication.activeModalWidget()\n dialogs = []\n for widget in app.topLevelWidgets():\n if isinstance(widget, QFileDialog):\n filename = widget.findChild(QLineEdit, \"fileNameEdit\")\n dialogs.append({\n \"class\": type(widget).__name__,\n \"title\": widget.windowTitle(),\n \"visible\": widget.isVisible(),\n \"modal\": widget.isModal(),\n \"directory\": widget.directory().path(),\n \"selected_files\": widget.selectedFiles(),\n \"filename_edit\": None if filename is None else filename.text(),\n })\n _emit(\"qt-modal\", node_id=item.nodeid, tick=ticks[0],\n dont_use_native=QApplication.testAttribute(\n Qt.ApplicationAttribute.AA_DontUseNativeDialogs),\n active_class=None if active is None else type(active).__name__,\n active_title=None if active is None else active.windowTitle(),\n dialogs=dialogs)\n timer = QTimer(app)\n timer.timeout.connect(observe)\n timer.start(2000)\n _timers.append(timer)\n observe()\n\ndef pytest_runtest_teardown(item):\n if item.path.name == \"test_analysis_predictions.py\":\n sys.setprofile(_old_profile)\n while _timers:\n timer = _timers.pop()\n timer.stop()\n timer.deleteLater()\n _emit(\"teardown\", node_id=item.nodeid)\n" + (out / "capture_prediction_observer.py").write_text(plugin, encoding="utf-8", newline="\n") + env = dict(os.environ) + env["QT_QPA_PLATFORM"] = "offscreen" + env["PYTHONUNBUFFERED"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONDONTWRITEBYTECODE"] = "1" + env["PYTHONPATH"] = str(out) + os.pathsep + str(root) + env["PYTEST_PLUGINS"] = "capture_prediction_observer" + command = [sys.executable, "-u", "-B", "-m", "pytest", "tests", "-vv", "-s", + "-o", "faulthandler_timeout=20", "--ignore=tests/kill_tests", + "--junitxml="+str(out / "pytest.xml")] + started=time.monotonic() + timeout=False + try: + result=subprocess.run(command,cwd=root,env=env,stdout=subprocess.PIPE, + stderr=subprocess.STDOUT,timeout=150) + raw=result.stdout + code=result.returncode + except subprocess.TimeoutExpired as exc: + timeout=True + raw=exc.stdout or b"" + code=None + (out / "pytest.log").write_bytes(raw) + print(raw.decode("utf-8",errors="replace"),flush=True) + after=source_snapshot(root) + receipt={"kind":"diagnosis-only","source":expected, + "source_unchanged":before==after, + "source_file_count":len(before["files"]), + "source_tree_sha256":before["tree_sha256"], + "python":sys.version,"platform":platform.platform(), + "pyside":PySide6.__version__,"command":command, + "timeout_seconds":150,"timed_out":timeout,"process_exit":code, + "seconds":time.monotonic()-started, + "log_sha256":hashlib.sha256(raw).hexdigest(), + "observer_sha256":hashlib.sha256(plugin.encode()).hexdigest(), + "observer":"read-only node/Qt modal fields; no file-dialog or worker replacement", + "ordinary_ci_gate_accepted":False} + (out / "receipt.json").write_text(json.dumps(receipt,indent=2)+"\n",encoding="utf-8") + print(json.dumps(receipt,indent=2),flush=True) + sys.exit(1 if timeout or code != 0 or before != after else 0) + + '@ | python -u - + - name: Retain exact diagnostic evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: capture84-windows-diagnostic + path: build/evidence/prediction-input-windows-diagnostic/ + if-no-files-found: error + retention-days: 7 diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/final-correction-source.json b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/final-correction-source.json new file mode 100644 index 00000000..1e9f6424 --- /dev/null +++ b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/final-correction-source.json @@ -0,0 +1,103 @@ +{ + "claim": "Windows successor source/preflight; ordinary acceptance pending", + "source_screen": { + "blob": "83bd8e5f642fb389dcd1a5f4b5820bfba0140086", + "sha256": "c3285b0fdd6c67dbe80f38cbce843f8aa3a8623f1d1e0a10b5c33d3c109a24c2" + }, + "tests_blob": "6573e5998aa49fc4f99579d45c97d7374cd391ec", + "observer_blob": "742d3e1fe90a6dbe6cd768badb843bd836b1367a", + "ci_blob": "1067e9944ce4836d8cd1673442687333ef7ad564", + "scope": { + "path": "tests/ui/test_analysis_scope.py", + "git_blob": "817412fe4352c527ad7da8406269ac67cede1709", + "owner": "estate-7879c2abc07f", + "pr": 79, + "owner_head": "7f5548fb12e040f45d7e1f5867d511dc33a618ac", + "preimage_blob": "2ee653a05e27e8586e74439d2bdc9eccf06505ef", + "only_delta": "Two 1 ms time.sleep calls and one comment; inverse equals exact current preimage.", + "original_timeout_preserved_seconds": 120, + "ordinary_gate": { + "run": 37821319011, + "job": 113462781924, + "checkout": "c062c338df1a2e11e927ab9efb189c062f0ea189", + "passed": 559, + "skipped": 6, + "seconds": 102.7499045, + "process_exit": 0, + "junit_sha256": "d154115183f9d92751a047022db5c4f6e027ba12b8e4c64249f003572ff6ded8" + }, + "receiving_comments": [ + 6067513793, + 6067559494, + 6067573862 + ], + "no_other_pr79_path_adopted": true + }, + "observer_preflight": { + "observer_sha256": "c3df198f02b51e2f44b3adcdbe15e23dd015a27cc11b088f559cf48129ccbbae", + "fixtures": [ + { + "case": "exact-selection", + "accepted": true + }, + { + "case": "bounded-refusal", + "accepted": true + }, + { + "case": "skipped-main", + "accepted": false + }, + { + "case": "duplicate-main", + "accepted": false + }, + { + "case": "bad-hash", + "accepted": false + }, + { + "case": "missing-counter", + "accepted": false + }, + { + "case": "missing-property", + "accepted": false + }, + { + "case": "wrong-branch", + "accepted": false + } + ], + "claim": "in-memory observation/assertion preflight only; no Windows execution" + }, + "logs": [ + { + "job": 113498048445, + "run": 37831595597, + "gzip_bytes": 35382, + "gzip_sha256": "dc850b544f50278dbca87f6c5b245b54f85250697b7893686a8ef1795b7111e5", + "raw_bytes": 269777, + "raw_sha256": "b15aa1c400aa45e415e0ba52860be52aa8ddcd64ccfadffcd538e8d67f9880f6", + "git_blob": "06d8d3adaa1158dbc569447005f60df44214558e" + }, + { + "job": 113500634388, + "run": 37832352721, + "gzip_bytes": 25063, + "gzip_sha256": "df7662caa116a704b84ff228471f9b541b56a81cc12a470a605895b2807e8dfb", + "raw_bytes": 153989, + "raw_sha256": "550f14707f0e41e4761818dc6df0883c26cf3720784ec42b992e132632847a28", + "git_blob": "539d6c0dcad942a457866f9a8fe68f99cf8861de" + }, + { + "job": 113502868940, + "run": 37833005840, + "gzip_bytes": 25098, + "gzip_sha256": "757af17411c8a6054183a4d04f3e329a335bb5ee41ef898532e3838a3d6bc541", + "raw_bytes": 156287, + "raw_sha256": "dbbdf22850b1d6a9574e87f98bcc7b48b4e43d6ab8b76dff35f364803eb5df19", + "git_blob": "8684bdf88163a9487f3c2916e80e561eb58d87d2" + } + ] +} diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/first-windows-failure.tar.gz b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/first-windows-failure.tar.gz new file mode 100644 index 00000000..cd292994 Binary files /dev/null and b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/first-windows-failure.tar.gz differ diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-job-113498048445.log.gz b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-job-113498048445.log.gz new file mode 100644 index 00000000..06d8d3ad Binary files /dev/null and b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-job-113498048445.log.gz differ diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-job-113500634388.log.gz b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-job-113500634388.log.gz new file mode 100644 index 00000000..539d6c0d Binary files /dev/null and b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-job-113500634388.log.gz differ diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-job-113502868940.log.gz b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-job-113502868940.log.gz new file mode 100644 index 00000000..8684bdf8 Binary files /dev/null and b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-job-113502868940.log.gz differ diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-workflow-r5.yml b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-workflow-r5.yml new file mode 100644 index 00000000..bea00fd3 --- /dev/null +++ b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/focused-workflow-r5.yml @@ -0,0 +1,98 @@ +name: Capture84 exact-source Windows diagnosis + +on: + push: + branches: ["diagnostic/capture84-windows-6e5752b49b6f"] + +permissions: + contents: read + +jobs: + diagnose: + runs-on: windows-2022 + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + with: + ref: e3cab7a612cecf5c049c0e60ddc8679726978fb1 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install existing project CI dependencies + run: python -m pip install -r requirements-ci.txt + - name: Reproduce existing generated-source preparation + run: | + python tools/gen_protos.py + python tools/gen_session_schemas.py + git diff --exit-code + - name: Receive corrected chooser and keyboard on exact current composition + shell: pwsh + env: + QT_QPA_PLATFORM: offscreen + PYTHONIOENCODING: utf-8 + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" + run: | + @' + import hashlib, json, os, pathlib, platform, subprocess, sys, time + from tools.run_ci_tests import source_snapshot + import PySide6 + root = pathlib.Path.cwd() + out = root / "build/evidence/prediction-input-windows-diagnostic" + out.mkdir(parents=True, exist_ok=False) + expected = "e3cab7a612cecf5c049c0e60ddc8679726978fb1" + before = source_snapshot(root) + assert before["commit"] == expected and not before["worktree_dirty"] + (out / "source-before.json").write_text(json.dumps(before, indent=2)+"\n", encoding="utf-8") + plugin = "# SPDX-License-Identifier: GPL-3.0-only\n\"\"\"Read-only node and Qt modal observations for the isolated Windows diagnosis.\"\"\"\nimport json\nimport time\nimport sys\n\n_timers = []\n_started = time.monotonic()\n_old_profile = None\n\ndef _emit(kind, **fields):\n print(\"CAPTURE_DIAGNOSTIC \" + json.dumps(\n {\"kind\": kind, \"elapsed\": time.monotonic() - _started, **fields},\n ensure_ascii=True, sort_keys=True), flush=True)\n\ndef pytest_collection_finish(session):\n _emit(\"collection\", count=len(session.items),\n node_ids=[item.nodeid for item in session.items])\n\ndef pytest_runtest_call(item):\n _emit(\"call\", node_id=item.nodeid)\n if item.path.parent.name != \"ui\":\n return\n from PySide6.QtCore import QTimer, Qt\n from PySide6.QtWidgets import QApplication, QFileDialog, QLineEdit\n global _old_profile\n _old_profile = sys.getprofile()\n def profile(frame, event, _arg):\n if (event in (\"call\", \"return\")\n and frame.f_code.co_name == \"finish\"\n and frame.f_code.co_filename.endswith(\"test_analysis_predictions.py\")):\n fields = {\"event\": event, \"node_id\": item.nodeid,\n \"dont_use_native\": QApplication.testAttribute(\n Qt.ApplicationAttribute.AA_DontUseNativeDialogs)}\n dialog = frame.f_locals.get(\"dialog\", frame.f_locals.get(\"active\"))\n try:\n if isinstance(dialog, QFileDialog):\n fields.update(dialog_class=type(dialog).__name__,\n visible=dialog.isVisible(),\n selected_files=dialog.selectedFiles())\n except RuntimeError as exc:\n fields[\"dialog_read_error\"] = str(exc)\n _emit(\"finish-callback\", **fields)\n sys.setprofile(profile)\n app = QApplication.instance()\n if app is None:\n return\n ticks = [0]\n def observe():\n ticks[0] += 1\n active = QApplication.activeModalWidget()\n dialogs = []\n for widget in app.topLevelWidgets():\n if isinstance(widget, QFileDialog):\n filename = widget.findChild(QLineEdit, \"fileNameEdit\")\n dialogs.append({\n \"class\": type(widget).__name__,\n \"title\": widget.windowTitle(),\n \"visible\": widget.isVisible(),\n \"modal\": widget.isModal(),\n \"directory\": widget.directory().path(),\n \"selected_files\": widget.selectedFiles(),\n \"filename_edit\": None if filename is None else filename.text(),\n })\n _emit(\"qt-modal\", node_id=item.nodeid, tick=ticks[0],\n dont_use_native=QApplication.testAttribute(\n Qt.ApplicationAttribute.AA_DontUseNativeDialogs),\n active_class=None if active is None else type(active).__name__,\n active_title=None if active is None else active.windowTitle(),\n dialogs=dialogs)\n timer = QTimer(app)\n timer.timeout.connect(observe)\n timer.start(2000)\n _timers.append(timer)\n observe()\n\ndef pytest_runtest_teardown(item):\n if item.path.parent.name == \"ui\":\n sys.setprofile(_old_profile)\n while _timers:\n timer = _timers.pop()\n timer.stop()\n timer.deleteLater()\n _emit(\"teardown\", node_id=item.nodeid)\n\n\ndef pytest_runtest_logreport(report):\n _emit(\"report\", node_id=report.nodeid, phase=report.when, outcome=report.outcome)\n if report.failed:\n _emit(\"failure\", node_id=report.nodeid, phase=report.when,\n longrepr=str(report.longrepr),\n sections=[{\"name\": name, \"content\": content}\n for name, content in report.sections])\n" + (out / "capture_prediction_observer.py").write_text(plugin, encoding="utf-8", newline="\n") + env = dict(os.environ) + env["QT_QPA_PLATFORM"] = "offscreen" + env["PYTHONUNBUFFERED"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONDONTWRITEBYTECODE"] = "1" + env["PYTHONPATH"] = str(out) + os.pathsep + str(root) + env["PYTEST_PLUGINS"] = "capture_prediction_observer" + command = [sys.executable, "-u", "-B", "-m", "pytest", "tests/ui/test_analysis_predictions.py", + "tests/protocol/test_ci_contract.py", "-vv", "-s", + "-o", "faulthandler_timeout=20", "--ignore=tests/kill_tests", + "--junitxml="+str(out / "pytest.xml")] + started=time.monotonic() + timeout=False + try: + result=subprocess.run(command,cwd=root,env=env,stdout=subprocess.PIPE, + stderr=subprocess.STDOUT,timeout=150) + raw=result.stdout + code=result.returncode + except subprocess.TimeoutExpired as exc: + timeout=True + raw=exc.stdout or b"" + code=None + (out / "pytest.log").write_bytes(raw) + print(raw.decode("utf-8",errors="replace"),flush=True) + after=source_snapshot(root) + receipt={"kind":"diagnosis-only","source":expected, + "source_unchanged":before==after, + "source_file_count":len(before["files"]), + "source_tree_sha256":before["tree_sha256"], + "python":sys.version,"platform":platform.platform(), + "pyside":PySide6.__version__,"command":command, + "timeout_seconds":150,"timed_out":timeout,"process_exit":code, + "seconds":time.monotonic()-started, + "log_sha256":hashlib.sha256(raw).hexdigest(), + "observer_sha256":hashlib.sha256(plugin.encode()).hexdigest(), + "observer":"read-only node/Qt modal fields; no file-dialog or worker replacement", + "ordinary_ci_gate_accepted":False} + (out / "receipt.json").write_text(json.dumps(receipt,indent=2)+"\n",encoding="utf-8") + print(json.dumps(receipt,indent=2),flush=True) + sys.exit(1 if timeout or code != 0 or before != after else 0) + + '@ | python -u - + - name: Retain exact diagnostic evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: capture84-windows-diagnostic + path: build/evidence/prediction-input-windows-diagnostic/ + if-no-files-found: error + retention-days: 7 diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/literal-model-workflow-r6.yml b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/literal-model-workflow-r6.yml new file mode 100644 index 00000000..6c0a4777 --- /dev/null +++ b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/literal-model-workflow-r6.yml @@ -0,0 +1,98 @@ +name: Capture84 exact-source Windows diagnosis + +on: + push: + branches: ["diagnostic/capture84-windows-6e5752b49b6f"] + +permissions: + contents: read + +jobs: + diagnose: + runs-on: windows-2022 + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + with: + ref: e3cab7a612cecf5c049c0e60ddc8679726978fb1 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install existing project CI dependencies + run: python -m pip install -r requirements-ci.txt + - name: Reproduce existing generated-source preparation + run: | + python tools/gen_protos.py + python tools/gen_session_schemas.py + git diff --exit-code + - name: Observe literal fixture and actual Qt file-model rows on exact source + shell: pwsh + env: + QT_QPA_PLATFORM: offscreen + PYTHONIOENCODING: utf-8 + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" + run: | + @' + import hashlib, json, os, pathlib, platform, subprocess, sys, time + from tools.run_ci_tests import source_snapshot + import PySide6 + root = pathlib.Path.cwd() + out = root / "build/evidence/prediction-input-windows-diagnostic" + out.mkdir(parents=True, exist_ok=False) + expected = "e3cab7a612cecf5c049c0e60ddc8679726978fb1" + before = source_snapshot(root) + assert before["commit"] == expected and not before["worktree_dirty"] + (out / "source-before.json").write_text(json.dumps(before, indent=2)+"\n", encoding="utf-8") + plugin = "# SPDX-License-Identifier: GPL-3.0-only\n\"\"\"Read-only node and Qt modal observations for the isolated Windows diagnosis.\"\"\"\nimport json\nimport time\nimport sys\nimport os\n\n_timers = []\n_started = time.monotonic()\n_old_profile = None\n\ndef _emit(kind, **fields):\n print(\"CAPTURE_DIAGNOSTIC \" + json.dumps(\n {\"kind\": kind, \"elapsed\": time.monotonic() - _started, **fields},\n ensure_ascii=True, sort_keys=True, default=str), flush=True)\n\ndef pytest_collection_finish(session):\n _emit(\"collection\", count=len(session.items),\n node_ids=[item.nodeid for item in session.items])\n\ndef pytest_runtest_call(item):\n _emit(\"call\", node_id=item.nodeid)\n if item.path.parent.name != \"ui\":\n return\n from PySide6.QtCore import QTimer, Qt\n from PySide6.QtWidgets import QApplication, QFileDialog, QLineEdit, QAbstractItemView\n global _old_profile\n _old_profile = sys.getprofile()\n def profile(frame, event, _arg):\n if (event in (\"call\", \"return\")\n and frame.f_code.co_name == \"finish\"\n and frame.f_code.co_filename.endswith(\"test_analysis_predictions.py\")):\n fields = {\"event\": event, \"node_id\": item.nodeid,\n \"dont_use_native\": QApplication.testAttribute(\n Qt.ApplicationAttribute.AA_DontUseNativeDialogs)}\n dialog = frame.f_locals.get(\"dialog\", frame.f_locals.get(\"active\"))\n try:\n if isinstance(dialog, QFileDialog):\n fields.update(dialog_class=type(dialog).__name__,\n visible=dialog.isVisible(),\n selected_files=dialog.selectedFiles())\n except RuntimeError as exc:\n fields[\"dialog_read_error\"] = str(exc)\n _emit(\"finish-callback\", **fields)\n sys.setprofile(profile)\n app = QApplication.instance()\n if app is None:\n return\n ticks = [0]\n def observe():\n ticks[0] += 1\n active = QApplication.activeModalWidget()\n dialogs = []\n for widget in app.topLevelWidgets():\n if isinstance(widget, QFileDialog):\n filename = widget.findChild(QLineEdit, \"fileNameEdit\")\n views = []\n for view in widget.findChildren(QAbstractItemView):\n model = view.model()\n root = view.rootIndex()\n views.append({\n \"class\": type(view).__name__, \"name\": view.objectName(),\n \"visible\": view.isVisible(), \"root\": root.data(),\n \"model\": type(model).__name__,\n \"row_count\": model.rowCount(root),\n \"rows\": [\n {\"display\": model.index(row, 0, root).data(),\n \"file_path_role\": model.index(row, 0, root).data(257)}\n for row in range(min(model.rowCount(root), 30))\n ],\n })\n try:\n filesystem_names = os.listdir(widget.directory().path())\n except OSError as exc:\n filesystem_names = [repr(exc)]\n dialogs.append({\n \"class\": type(widget).__name__,\n \"views\": views,\n \"filesystem_names\": filesystem_names,\n \"name_filters\": widget.nameFilters(),\n \"selected_name_filter\": widget.selectedNameFilter(),\n \"title\": widget.windowTitle(),\n \"visible\": widget.isVisible(),\n \"modal\": widget.isModal(),\n \"directory\": widget.directory().path(),\n \"selected_files\": widget.selectedFiles(),\n \"filename_edit\": None if filename is None else filename.text(),\n })\n _emit(\"qt-modal\", node_id=item.nodeid, tick=ticks[0],\n dont_use_native=QApplication.testAttribute(\n Qt.ApplicationAttribute.AA_DontUseNativeDialogs),\n active_class=None if active is None else type(active).__name__,\n active_title=None if active is None else active.windowTitle(),\n dialogs=dialogs)\n timer = QTimer(app)\n timer.timeout.connect(observe)\n timer.start(2000)\n _timers.append(timer)\n observe()\n\ndef pytest_runtest_teardown(item):\n if item.path.parent.name == \"ui\":\n sys.setprofile(_old_profile)\n while _timers:\n timer = _timers.pop()\n timer.stop()\n timer.deleteLater()\n _emit(\"teardown\", node_id=item.nodeid)\n\n\ndef pytest_runtest_logreport(report):\n _emit(\"report\", node_id=report.nodeid, phase=report.when, outcome=report.outcome)\n if report.failed:\n _emit(\"failure\", node_id=report.nodeid, phase=report.when,\n longrepr=str(report.longrepr),\n sections=[{\"name\": name, \"content\": content}\n for name, content in report.sections])\n" + (out / "capture_prediction_observer.py").write_text(plugin, encoding="utf-8", newline="\n") + env = dict(os.environ) + env["QT_QPA_PLATFORM"] = "offscreen" + env["PYTHONUNBUFFERED"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONDONTWRITEBYTECODE"] = "1" + env["PYTHONPATH"] = str(out) + os.pathsep + str(root) + env["PYTEST_PLUGINS"] = "capture_prediction_observer" + command = [sys.executable, "-u", "-B", "-m", "pytest", "tests/ui/test_analysis_predictions.py::test_actual_qt_file_choice_cancel_and_reselection", + "-vv", "-s", + "-o", "faulthandler_timeout=20", "--ignore=tests/kill_tests", + "--junitxml="+str(out / "pytest.xml")] + started=time.monotonic() + timeout=False + try: + result=subprocess.run(command,cwd=root,env=env,stdout=subprocess.PIPE, + stderr=subprocess.STDOUT,timeout=150) + raw=result.stdout + code=result.returncode + except subprocess.TimeoutExpired as exc: + timeout=True + raw=exc.stdout or b"" + code=None + (out / "pytest.log").write_bytes(raw) + print(raw.decode("utf-8",errors="replace"),flush=True) + after=source_snapshot(root) + receipt={"kind":"diagnosis-only","source":expected, + "source_unchanged":before==after, + "source_file_count":len(before["files"]), + "source_tree_sha256":before["tree_sha256"], + "python":sys.version,"platform":platform.platform(), + "pyside":PySide6.__version__,"command":command, + "timeout_seconds":150,"timed_out":timeout,"process_exit":code, + "seconds":time.monotonic()-started, + "log_sha256":hashlib.sha256(raw).hexdigest(), + "observer_sha256":hashlib.sha256(plugin.encode()).hexdigest(), + "observer":"read-only node/Qt modal fields; no file-dialog or worker replacement", + "ordinary_ci_gate_accepted":False} + (out / "receipt.json").write_text(json.dumps(receipt,indent=2)+"\n",encoding="utf-8") + print(json.dumps(receipt,indent=2),flush=True) + sys.exit(1 if timeout or code != 0 or before != after else 0) + + '@ | python -u - + - name: Retain exact diagnostic evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: capture84-windows-diagnostic + path: build/evidence/prediction-input-windows-diagnostic/ + if-no-files-found: error + retention-days: 7 diff --git a/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/ordinary-job-113481790199.log.gz b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/ordinary-job-113481790199.log.gz new file mode 100644 index 00000000..4a66925c Binary files /dev/null and b/docs/evidence/analysis-predictions-windows-correction-6e5752b49b6f/ordinary-job-113481790199.log.gz differ diff --git a/tests/ui/test_analysis_predictions.py b/tests/ui/test_analysis_predictions.py new file mode 100644 index 00000000..6573e599 --- /dev/null +++ b/tests/ui/test_analysis_predictions.py @@ -0,0 +1,761 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Native evaluation input controls and the actual workbench consumer.""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from PySide6.QtCore import QEventLoop, Qt, QThread, QTimer +from PySide6.QtGui import QPixmap +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox + + +@pytest.fixture +def extras(qapp, tmp_path): + from capture_desktop.widgets_analysis_jobs import AnalysisJobExtras + + widget = AnalysisJobExtras() + widget.set_package(str(tmp_path)) + widget.set_command("eval") + widget._bundle_job.setEditText("bundle") + widget.show() + qapp.processEvents() + yield widget + widget.close() + widget.deleteLater() + qapp.processEvents() + + +def _external(extras): + control = extras._evaluation + control._mode.setCurrentIndex(control._mode.findData("external")) + return control + + +def _chosen(control, monkeypatch, filename): + monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *_a, **_k: (str(filename), "")) + control._choose_button.click() + + +def _real_choose(control, filename: Path | None): + """Drive the actual Qt file dialog with literal names and bounded cleanup.""" + from PySide6.QtWidgets import QAbstractItemView, QDialogButtonBox, QLineEdit + + previous = QApplication.testAttribute(Qt.ApplicationAttribute.AA_DontUseNativeDialogs) + QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeDialogs, True) + selected = [] + failures = [] + started = time.monotonic() + attempted = False + prepared = False + row_clicked = False + selection_ticks = 0 + driver = QTimer() + watchdog = QTimer() + + def unwind(reason): + if not failures: + failures.append(reason) + # A failed accept can enter a second modal loop. Close that message + # before the file dialog, so both nested loops return to the assertion. + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QMessageBox) and widget.isVisible(): + widget.reject() + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QFileDialog) and widget.isVisible(): + widget.reject() + + def check_deadline(): + active = QApplication.activeModalWidget() + if isinstance(active, QMessageBox): + unwind(f"Unexpected file-dialog message: {active.text()}") + elif time.monotonic() - started >= 5: + unwind("The actual Qt file dialog did not complete within five seconds.") + + def finish(): + nonlocal attempted, prepared, row_clicked, selection_ticks + try: + active = QApplication.activeModalWidget() + if failures or attempted or not isinstance(active, QFileDialog): + return + if filename is None: + attempted = True + driver.stop() + selected.append(None) + active.reject() + return + if not prepared: + active.selectNameFilter("All files (*)") + active.setDirectory(str(filename.parent)) + prepared = True + return + + # Existing fixtures are selected through the actual file view. + # Match the model's literal filename; never accept a normalized + # substitute. Some Qt widget models cannot represent every OS name. + if filename.is_file() and not row_clicked: + for view in active.findChildren(QAbstractItemView): + if not view.isVisible() or view.objectName() not in ("listView", "treeView"): + continue + model = view.model() + for row in range(model.rowCount(view.rootIndex())): + index = model.index(row, 0, view.rootIndex()) + if index.data() != filename.name: + continue + view.scrollTo(index) + rectangle = view.visualRect(index) + if not rectangle.isValid(): + continue + QTest.mouseClick( + view.viewport(), Qt.MouseButton.LeftButton, pos=rectangle.center() + ) + row_clicked = True + return + return + if not filename.is_file(): + # The deliberate missing-file regression exercises Qt's error + # dialog and the independent watchdog, rather than a mock. + edit = active.findChild(QLineEdit, "fileNameEdit") + assert edit is not None + edit.setFocus() + edit.setText(f'"{filename.name}"') + else: + selection_ticks += 1 + if selection_ticks < 3: + return + + actual = active.selectedFiles() + assert len(actual) == 1 and Path(actual[0]) == filename, ( + f"Qt selected {actual!r}, expected literal path {str(filename)!r}" + ) + if filename.is_file(): + assert Path(actual[0]).samefile(filename) + assert Path(actual[0]).name == filename.name + selected.extend(actual) + attempted = True + driver.stop() + if filename.is_file(): + box = active.findChild(QDialogButtonBox) + assert box is not None + button = next( + button for button in box.buttons() + if box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole + ) + assert button.isEnabled() + QTest.mouseClick(button, Qt.MouseButton.LeftButton) + else: + active.accept() + except Exception as exc: + unwind(f"Actual Qt file choice failed: {exc!r}") + + driver.timeout.connect(finish) + watchdog.timeout.connect(check_deadline) + driver.start(40) + watchdog.start(25) + try: + control._choose_button.click() + finally: + driver.stop() + watchdog.stop() + QApplication.setAttribute( + Qt.ApplicationAttribute.AA_DontUseNativeDialogs, previous + ) + assert not failures, failures + assert attempted and len(selected) == 1 + return selected[0] + + +def test_default_identity_and_roundtrip_omit_the_retained_external_path( + extras, monkeypatch, tmp_path +): + assert extras.build_extra("eval") == {"ml_bundle_job_id": "bundle"} + assert extras.validate_for("eval") is None + control = _external(extras) + path = tmp_path / " predictions café .json" + path.write_text("{}", encoding="utf-8") + _chosen(control, monkeypatch, path) + control._mode.setCurrentIndex(control._mode.findData("identity")) + assert extras.build_extra("eval") == {"ml_bundle_job_id": "bundle"} + assert extras.validate_for("eval") is None + control._mode.setCurrentIndex(control._mode.findData("external")) + assert extras.build_extra("eval")["prediction_path"] == str(path) + + +def test_empty_external_choice_is_explicit_and_actionable(extras): + _external(extras) + assert extras.build_extra("eval") == {"ml_bundle_job_id": "bundle", "prediction_path": ""} + assert "Choose a predictions JSON file" in extras.validate_for("eval") + + +def test_actual_qt_file_choice_cancel_and_reselection(extras, tmp_path): + control = _external(extras) + first = tmp_path / "first prediction café .json" + second = tmp_path / "second prediction.json" + first.write_text("{}", encoding="utf-8") + second.write_text("{}", encoding="utf-8") + actual = _real_choose(control, first) + assert Path(actual).samefile(first) + assert control._path.text() == actual + assert extras.validate_for("eval") is None + _real_choose(control, None) + assert control._path.text() == actual + newer = _real_choose(control, second) + assert Path(newer).samefile(second) + assert extras.build_extra("eval")["prediction_path"] == newer + + +def test_actual_qt_leading_space_is_exact_or_refuses_without_replacing_path( + extras, tmp_path, record_property +): + control = _external(extras) + kept = tmp_path / "kept prediction.json" + literal = tmp_path / " first prediction .json" + kept.write_text("{}", encoding="utf-8") + literal.write_text("{}", encoding="utf-8") + previous = _real_choose(control, kept) + assert control._path.text() == previous + started = time.monotonic() + try: + selected = _real_choose(control, literal) + except AssertionError as exc: + # Qt 6.12's Windows widget model was observed dropping this leading + # space despite the literal OS file existing. Refusal must be bounded + # and leave the previously admitted path untouched. + assert time.monotonic() - started < 6 + assert "file dialog" in str(exc) or "file choice" in str(exc) + assert QApplication.activeModalWidget() is None + assert control._path.text() == previous + assert extras.build_extra("eval")["prediction_path"] == previous + branch = "bounded-refusal" + else: + assert Path(selected) == literal + assert Path(selected).samefile(literal) + assert Path(selected).name == literal.name + assert control._path.text() == selected + assert extras.build_extra("eval")["prediction_path"] == selected + branch = "exact-selection" + assert literal.name in os.listdir(tmp_path) + record_property("literal_file_choice", branch) + print(f"Literal leading-space Qt widget choice: {branch}", flush=True) + + +def test_actual_qt_file_choice_error_unwinds_without_replacing_path( + extras, monkeypatch, tmp_path +): + control = _external(extras) + kept = tmp_path / "kept.json" + kept.write_text("{}") + with monkeypatch.context() as patch: + _chosen(control, patch, kept) + # The failing attempt uses the real Qt dialog after the seed hook is gone. + started = time.monotonic() + with pytest.raises(AssertionError, match="file.dialog"): + _real_choose(control, tmp_path / "does-not-exist.json") + assert time.monotonic() - started < 6 + assert QApplication.activeModalWidget() is None + assert control._path.text() == str(kept) + + +def test_clear_keeps_external_and_moves_keyboard_focus_to_choose(extras, monkeypatch, tmp_path): + control = _external(extras) + path = tmp_path / "file.json" + path.write_text("{}") + _chosen(control, monkeypatch, path) + control._clear_button.setFocus() + QTest.keyClick(control._clear_button, Qt.Key.Key_Space) + assert control._mode.currentData() == "external" + assert extras.build_extra("eval")["prediction_path"] == "" + assert not control._clear_button.isEnabled() + assert QApplication.focusWidget() is control._choose_button + + +@pytest.mark.parametrize("kind", ["missing", "directory", "nul"]) +def test_unavailable_file_never_becomes_identity(extras, monkeypatch, tmp_path, kind): + control = _external(extras) + filename = { + "missing": str(tmp_path / "gone.json"), "directory": str(tmp_path), "nul": "\0", + }[kind] + _chosen(control, monkeypatch, filename) + assert "unavailable" in extras.validate_for("eval") + assert extras.build_extra("eval")["prediction_path"] == filename + + +def test_removed_file_choice_is_retained_for_reselection(extras, monkeypatch, tmp_path): + control = _external(extras) + path = tmp_path / "removed.json" + path.write_text("{}") + _chosen(control, monkeypatch, path) + assert extras.validate_for("eval") is None + path.unlink() + assert extras.validate_for("eval") is not None + assert control._path.text() == str(path) + assert control._mode.currentData() == "external" + + +@pytest.mark.parametrize( + "command", ["qc", "features", "plots", "all", "pose", "kinematics", "ml_bundle"], +) +def test_other_commands_never_receive_prediction_path_and_keep_legacy_fields( + extras, monkeypatch, tmp_path, command +): + control = _external(extras) + path = tmp_path / "chosen.json" + path.write_text("{}") + _chosen(control, monkeypatch, path) + extras._pose_job.setEditText("pose-original") + extras._kin_job.setEditText("kin-original") + extras._feat_job.setEditText("features-original") + extras._window_sec.setText("2.5") + extras._hop_sec.setText("0.125") + extras.set_command(command) + assert control.isHidden() + actual = extras.build_extra(command) + assert "prediction_path" not in actual + if command == "kinematics": + assert actual == {"pose_job_id": "pose-original"} + elif command == "ml_bundle": + assert actual == { + "kinematics_job_id": "kin-original", "features_job_id": "features-original", + "window_sec": 2.5, "hop_sec": 0.125, + } + extras.set_command("eval") + assert extras.build_extra("eval")["prediction_path"] == str(path) + + +def test_same_package_refresh_keeps_choice_and_new_package_clears_only_file( + extras, monkeypatch, tmp_path +): + control = _external(extras) + path = tmp_path / "selected.json" + path.write_text("{}") + _chosen(control, monkeypatch, path) + extras.set_package(str(tmp_path / ".")) + assert control._path.text() == str(path) + extras.set_package(str(tmp_path / "other")) + assert control._mode.currentData() == "external" + assert control._path.text() == "" + assert "prediction_path" in extras.build_extra("eval") + assert extras.validate_for("eval") is not None + + +@pytest.mark.parametrize("change", ["package", "command", "mode", "busy", "clear", "newer"]) +def test_old_modal_choice_cannot_replace_newer_context(extras, monkeypatch, tmp_path, change): + control = _external(extras) + paths = [tmp_path / (name + ".json") for name in ["kept", "late", "newer"]] + for path in paths: + path.write_text("{}") + _chosen(control, monkeypatch, paths[0]) + + def during_dialog(*_args, **_kwargs): + if change == "package": + extras.set_package(str(tmp_path / "other")) + elif change == "command": + extras.set_command("qc") + extras.set_command("eval") + elif change == "mode": + control._mode.setCurrentIndex(control._mode.findData("identity")) + control._mode.setCurrentIndex(control._mode.findData("external")) + elif change == "busy": + extras.set_eval_busy(True) + extras.set_eval_busy(False) + elif change == "clear": + control._clear() + else: + _chosen(control, monkeypatch, paths[2]) + return str(paths[1]), "" + + monkeypatch.setattr(QFileDialog, "getOpenFileName", during_dialog) + control._choose_button.click() + expected = ( + "" if change in {"package", "clear"} + else str(paths[2] if change == "newer" else paths[0]) + ) + assert control._path.text() == expected + + +def test_reserved_not_yet_started_worker_disables_run_and_allows_cancel(qapp): + from capture_desktop.screen_analysis import AnalysisScreen + from capture_desktop.state import CaptureState + + screen = AnalysisScreen(CaptureState()) + thread = QThread() + screen._thread = thread + try: + screen._sync_enabled() + assert not thread.isRunning() + assert not screen._extras._evaluation.isEnabled() + assert not screen._command.isEnabled() + assert not screen._btn_run.isEnabled() + assert screen._btn_cancel.isEnabled() + screen._summary = object() # Start must refuse even with an admitted package. + screen._start_job() + assert screen._thread is thread + screen._summary = None + finally: + screen._thread = None + screen._sync_enabled() + assert screen._extras._evaluation.isEnabled() + thread.deleteLater() + screen.close() + screen.deleteLater() + + +def _snapshot(package, predictions): + return { + str(p): hashlib.sha256(p.read_bytes()).hexdigest() + for p in [*package.rglob("*"), predictions] if p.is_file() + } + + +def _preserved(snapshot): + for filename, wanted in snapshot.items(): + assert hashlib.sha256(Path(filename).read_bytes()).hexdigest() == wanted + + +def _wait(screen, qapp, seconds=90): + if screen._thread is None: + return + # Use the same native event loop as the app. Repeated QTest.qWait calls can + # starve a Python worker rendering matplotlib figures on some Qt platforms. + loop = QEventLoop() + timer = QTimer() + timer.setSingleShot(True) + timer.timeout.connect(loop.quit) + screen._thread.finished.connect(loop.quit) + timer.start(seconds * 1000) + loop.exec() + timer.stop() + qapp.processEvents() + assert screen._thread is None, "Native worker did not finish within the bounded gate." + + +def _screen(qapp, package): + from capture_desktop.screen_analysis import AnalysisScreen + from capture_desktop.state import CaptureState + + screen = AnalysisScreen(CaptureState()) + screen.resize(1420, 1000) + screen.set_package(str(package)) + screen._command.setCurrentIndex(screen._command.findData("eval")) + screen._extras._bundle_job.setEditText("bundle") + screen.show() + qapp.processEvents() + return screen + + +def _finish_screen(screen, qapp): + if screen._thread is not None: + screen._cancel_job() + _wait(screen, qapp) + screen.close() + screen.deleteLater() + qapp.processEvents() + + +def test_actual_external_file_runs_through_native_workbench(qapp, tmp_path, monkeypatch): + from tests.analysis.test_external_predictions import _fixture + + package, predictions, windows, manifest = _fixture(tmp_path) + before = _snapshot(package, predictions) + errors = [] + monkeypatch.setattr(QMessageBox, "warning", lambda *_a: errors.append(_a[-1])) + screen = _screen(qapp, package) + try: + control = _external(screen._extras) + chosen = _real_choose(control, predictions) + screen._btn_run.click() + assert screen._thread is not None + assert not control.isEnabled() + assert not screen._btn_run.isEnabled() + assert screen._btn_cancel.isEnabled() + _wait(screen, qapp) + assert not errors + assert control.isEnabled() + assert screen._btn_run.isEnabled() + assert not screen._btn_cancel.isEnabled() + job = Path(screen._last_job_dir) + params = json.loads((job / "params.json").read_text()) + assert params["extra"] == {"ml_bundle_job_id": "bundle", "prediction_path": chosen} + report = json.loads((job / "eval/eval_report.json").read_text()) + assert report["evaluationMode"] == "external_predictions" + assert report["baseline"] is None + assert report["modelId"] == "synthetic-offset-control" + assert (job / "eval/prediction_input.json").read_bytes() == predictions.read_bytes() + assert report["inputProvenance"]["predictionsSha256"] == before[str(predictions)] + assert report["inputProvenance"]["windowsSha256"] == before[str(windows)] + assert report["inputProvenance"]["manifestSha256"] == before[str(manifest)] + figures = sorted((job / "eval/figures").glob("*.png")) + assert len(figures) == 2 and all(not QPixmap(str(p)).isNull() for p in figures) + assert screen._btn_open_job.isEnabled() + assert "completed" in screen._inspector._meta.text() + _preserved(before) + finally: + _finish_screen(screen, qapp) + + +def test_invalid_external_file_refuses_without_identity_then_retries(qapp, tmp_path, monkeypatch): + from tests.analysis.test_external_predictions import _fixture + + package, predictions, _windows, _manifest = _fixture(tmp_path) + bad = tmp_path / "wrong-source.json" + doc = json.loads(predictions.read_text()) + doc["windowsSha256"] = "0" * 64 + bad.write_text(json.dumps(doc), encoding="utf-8") + before = _snapshot(package, predictions) + errors = [] + monkeypatch.setattr(QMessageBox, "warning", lambda *_a: errors.append(_a[-1])) + screen = _screen(qapp, package) + try: + control = _external(screen._extras) + _real_choose(control, bad) + screen._btn_run.click() + _wait(screen, qapp) + assert len(errors) == 1 + assert control._mode.currentData() == "external" + assert Path(control._path.text()).samefile(bad) + assert not list((package / "processing/jobs").glob("*/eval/eval_report.json")) + assert screen._last_job_dir == "" + _real_choose(control, predictions) + screen._btn_run.click() + _wait(screen, qapp) + assert len(errors) == 1 + job = Path(screen._last_job_dir) + report = json.loads((job / "eval/eval_report.json").read_text()) + assert report["evaluationMode"] == "external_predictions" + assert (job / "eval/prediction_input.json").read_bytes() == predictions.read_bytes() + _preserved(before) + finally: + _finish_screen(screen, qapp) + + +def test_explicit_identity_after_external_choice_uses_original_job(qapp, tmp_path, monkeypatch): + from tests.analysis.test_external_predictions import _fixture + + package, predictions, _windows, _manifest = _fixture(tmp_path) + before = _snapshot(package, predictions) + errors = [] + monkeypatch.setattr(QMessageBox, "warning", lambda *_a: errors.append(_a[-1])) + screen = _screen(qapp, package) + try: + control = _external(screen._extras) + _real_choose(control, predictions) + control._mode.setCurrentIndex(control._mode.findData("identity")) + screen._btn_run.click() + _wait(screen, qapp) + assert not errors + job = Path(screen._last_job_dir) + assert json.loads((job / "params.json").read_text())["extra"] == { + "ml_bundle_job_id": "bundle", + } + report = json.loads((job / "eval/eval_report.json").read_text()) + assert report["baseline"] == "identity_teacher_sim" + assert not (job / "eval/prediction_input.json").exists() + _preserved(before) + finally: + _finish_screen(screen, qapp) + + +def _settle_control_layout(screen, qapp): + control = screen._extras._evaluation + + def ready(): + for widget in ( + control._mode, control._path, control._choose_button, control._clear_button, + ): + if widget.height() < widget.minimumSizeHint().height(): + return False + if not widget.parentWidget().rect().contains(widget.geometry()): + return False + help_height = control._help.heightForWidth(control._help.width()) + return ( + control._help.height() >= help_height + and control.rect().contains(control._help.geometry()) + and control.parentWidget().rect().contains(control.geometry()) + ) + + loop = QEventLoop() + timer = QTimer() + timer.timeout.connect(lambda: loop.quit() if ready() else None) + timer.start(10) + deadline = QTimer() + deadline.setSingleShot(True) + deadline.timeout.connect(loop.quit) + deadline.start(3000) + loop.exec() + timer.stop() + deadline.stop() + qapp.processEvents() + assert ready(), "Evaluation controls or wrapped instructions are clipped." + + +def _activate_window(window, qapp): + QApplication.setActiveWindow(window) # Qt activation; no OS-focus claim. + loop = QEventLoop() + poll = QTimer() + poll.timeout.connect( + lambda: loop.quit() if QApplication.activeWindow() is window else None + ) + deadline = QTimer() + deadline.setSingleShot(True) + deadline.timeout.connect(loop.quit) + poll.start(10) + deadline.start(3000) + loop.exec() + poll.stop() + deadline.stop() + qapp.processEvents() + assert QApplication.activeWindow() is window + + +def _check_scroll_keyboard(screen, window, qapp): + from PySide6.QtCore import QPoint, QRect + from PySide6.QtWidgets import QScrollArea + + scroll = screen.findChild(QScrollArea, "AnalysisControlsScroll") + assert scroll is not None + assert scroll.horizontalScrollBar().maximum() == 0 + control = screen._extras._evaluation + content = scroll.widget() + control_bottom = control.mapTo(content, QPoint()).y() + control.height() + assert control_bottom <= screen._btn_run.mapTo(content, QPoint()).y() + + def visible(widget): + rect = QRect(widget.mapTo(scroll.viewport(), QPoint()), widget.size()) + return scroll.viewport().rect().contains(rect) + + _activate_window(window, qapp) + scroll.verticalScrollBar().setValue(0) + screen._btn_browse.setFocus() + run_initially_clipped = not visible(screen._btn_run) + expected = { + "mode": control._mode, "path": control._path, "choose": control._choose_button, + "clear": control._clear_button, "run": screen._btn_run, + } + seen = set() + cleared = False + for _step in range(40): + focus = QApplication.focusWidget() + assert focus is not None + for name, widget in expected.items(): + if focus is widget: + assert visible(widget), f"Keyboard focus on {name} is outside the viewport." + seen.add(name) + if focus is screen._btn_run: + break + if focus is control._clear_button and not cleared: + QTest.keyClick(focus, Qt.Key.Key_Space) + qapp.processEvents() + assert control._path.text() == "" + assert control._mode.currentData() == "external" + assert QApplication.focusWidget() is control._choose_button + cleared = True + else: + QTest.keyClick(focus, Qt.Key.Key_Tab) + qapp.processEvents() + assert seen == set(expected) + assert cleared + assert visible(screen._btn_run) and visible(screen._btn_cancel) + if run_initially_clipped: + assert scroll.verticalScrollBar().value() > 0 + + +@pytest.mark.parametrize("size", [(1100, 700), (1280, 960)]) +def test_evaluation_controls_scroll_without_clipping(qapp, tmp_path, monkeypatch, size): + package = Path(__file__).resolve().parents[2] / "tests/fixtures/mini_session" + screen = _screen(qapp, package) + try: + control = _external(screen._extras) + path = tmp_path / " chosen predictions café .json" + path.write_text("{}") + _chosen(control, monkeypatch, path) + screen.resize(*size) + _settle_control_layout(screen, qapp) + assert (screen.width(), screen.height()) == size + _check_scroll_keyboard(screen, screen, qapp) + finally: + _finish_screen(screen, qapp) + + + +def test_analysis_button_space_overrides_only_its_application_shortcut( + qapp, tmp_path, monkeypatch +): + from capture_desktop.screen_analysis import AnalysisScreen + from capture_desktop.state import CaptureState + from PySide6.QtGui import QKeySequence, QShortcut + + screen = AnalysisScreen(CaptureState()) + seen = [] + shortcuts = [] + for key in ("Space", "C", "Ctrl+Space"): + shortcut = QShortcut(QKeySequence(key), screen) + shortcut.setContext(Qt.ShortcutContext.ApplicationShortcut) + shortcut.activated.connect(lambda name=key: seen.append(name)) + shortcuts.append(shortcut) + try: + screen._command.setCurrentIndex(screen._command.findData("eval")) + screen.resize(1280, 960) + screen.show() + _activate_window(screen, qapp) + control = _external(screen._extras) + path = tmp_path / "selected.json" + path.write_text("{}") + _chosen(control, monkeypatch, path) + control._clear_button.setFocus() + assert QApplication.focusWidget() is control._clear_button + QTest.keyClick(control._clear_button, Qt.Key.Key_Space) + assert control._path.text() == "" + assert seen == [] + + control._choose_button.setFocus() + QTest.keyClick(control._choose_button, Qt.Key.Key_C) + QTest.keyClick( + control._choose_button, Qt.Key.Key_Space, Qt.KeyboardModifier.ControlModifier + ) + assert seen == ["C", "Ctrl+Space"] + screen._banner.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + screen._banner.setFocus() + assert QApplication.focusWidget() is screen._banner + QTest.keyClick(screen._banner, Qt.Key.Key_Space) + assert seen == ["C", "Ctrl+Space", "Space"] + finally: + screen.close() + screen.deleteLater() + qapp.processEvents() + + +@pytest.mark.skipif(os.name != "nt", reason="Real MainWindow imports Windows named-pipe APIs.") +def test_evaluation_controls_in_actual_minimum_main_window(qapp, tmp_path, monkeypatch): + from capture_desktop.app import MainWindow + + window = MainWindow(auto_connect=False) + screen = window.analysis + try: + package = Path(__file__).resolve().parents[2] / "tests/fixtures/mini_session" + screen.set_package(str(package)) + screen._command.setCurrentIndex(screen._command.findData("eval")) + screen._extras._bundle_job.setEditText("bundle") + control = _external(screen._extras) + path = tmp_path / "predictions.json" + path.write_text("{}") + _chosen(control, monkeypatch, path) + window.tabs.setCurrentWidget(screen) + window.resize(1100, 700) + window.show() + _settle_control_layout(screen, qapp) + assert (window.width(), window.height()) == (1100, 700) + _check_scroll_keyboard(screen, window, qapp) + finally: + window.close() + window.deleteLater() + qapp.processEvents() diff --git a/tests/ui/test_analysis_scope.py b/tests/ui/test_analysis_scope.py index 2ee653a0..817412fe 100644 --- a/tests/ui/test_analysis_scope.py +++ b/tests/ui/test_analysis_scope.py @@ -127,6 +127,8 @@ def close_failure_dialog(): until = time.monotonic() + 120 while screen._thread is not None and time.monotonic() < until: QTest.qWait(20) + # Give the Python worker execution time as well as pumping Qt events. + time.sleep(0.001) assert screen._thread is None, "real analysis thread did not finish" assert not dialogs, dialogs assert screen._last_job_dir, screen._log.toPlainText() @@ -158,6 +160,7 @@ def close_failure_dialog(): # can otherwise leak a plotting thread into the next test. while screen._thread is not None: QTest.qWait(20) + time.sleep(0.001) timer.stop() _close_widgets(qapp, header, screen) diff --git a/tools/verify_prediction_workbench_ci.py b/tools/verify_prediction_workbench_ci.py new file mode 100644 index 00000000..742d3e1f --- /dev/null +++ b/tools/verify_prediction_workbench_ci.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-only +"""Read the ordinary Windows JUnit and require actual workbench consumer cases.""" + +import hashlib +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> None: + receipts = list((ROOT / "build/evidence").glob("python-*/receipt.json")) + assert len(receipts) == 1, "Expected one fresh ordinary test receipt" + receipt_path = receipts[0] + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + xml_path = receipt_path.with_name("pytest.xml") + raw = xml_path.read_bytes() + digest = hashlib.sha256(raw).hexdigest() + assert digest == receipt["junit_sha256"], "JUnit changed after the ordinary gate" + cases = [ + case for case in ET.fromstring(raw).iter("testcase") + if case.get("classname") == "tests.ui.test_analysis_predictions" + ] + results = [ + {"name": case.get("name"), "xml": ET.tostring(case, encoding="unicode")} + for case in cases + ] + print(json.dumps({ + "source_commit": receipt["source_commit"], + "source_tree_sha256": receipt["source_tree_sha256"], + "junit_sha256": digest, + "junit_bytes": len(raw), + "workbench_cases": results, + }, indent=2)) + assert receipt["accepted"] and receipt["process_exit"] == 0 + assert receipt["source_unchanged_during_test"] + required = ( + "test_evaluation_controls_in_actual_minimum_main_window", + "test_actual_qt_file_choice_cancel_and_reselection", + "test_actual_qt_file_choice_error_unwinds_without_replacing_path", + "test_analysis_button_space_overrides_only_its_application_shortcut", + "test_actual_qt_leading_space_is_exact_or_refuses_without_replacing_path", + ) + for name in required: + matching = [case for case in cases if case.get("name") == name] + assert len(matching) == 1, f"Expected exactly one actual case: {name}" + assert not any( + matching[0].find(tag) is not None + for tag in ("skipped", "failure", "error") + ), f"Required Windows case did not pass: {name}" + + + literal = next( + case for case in cases + if case.get("name") == + "test_actual_qt_leading_space_is_exact_or_refuses_without_replacing_path" + ) + branch = literal.findall("./properties/property[@name='literal_file_choice']") + assert len(branch) == 1 + assert branch[0].get("value") in ("exact-selection", "bounded-refusal") + + +if __name__ == "__main__": + main()