From 9b538c11c1c0263cfa7c6c40116ea758e026a526 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 12:54:51 -0500 Subject: [PATCH 01/32] fix: repair dic preprocess interactions and alignment --- .../+analysisRun/applyCropRoi.m | 2 + .../+analysisRun/autoAlignMovingToReference.m | 182 +++++++++++++----- .../+analysisRun/drawPreview.m | 2 + .../+dic_preprocess/+analysisRun/present.m | 4 +- .../+analysisRun/rebuildCache.m | 2 + .../+analysisRun/replayEditSteps.m | 3 +- .../+analysisRun/startPointMatching.m | 1 + .../+dic_preprocess/+maskEditing/present.m | 2 +- .../+dic_preprocess/+workbench/buildLayout.m | 4 +- .../+dic_preprocess/definition.m | 4 +- docs/apps/dic/dic-preprocess/README.md | 38 ++-- ...process-interaction-registration-repair.md | 76 ++++++++ .../analysisRun/DicPreprocessScientificSpec.m | 25 +++ .../workbench/DicPreprocessWorkflowSpec.m | 16 ++ 14 files changed, 291 insertions(+), 70 deletions(-) create mode 100644 docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m index f1ca2c505..176b2b51f 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m @@ -22,6 +22,8 @@ applicationState.project); applicationState = ... dic_preprocess.analysisRun.rebuildCache(applicationState); +applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; applicationState = ... dic_preprocess.analysisRun.stopEditors(applicationState); applicationState.project.parameters.previewMode = "Current pair"; diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m index 7eaf9965f..5acb34104 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m @@ -1,5 +1,5 @@ function [alignedImage, tformRigid, method] = autoAlignMovingToReference(referenceImage, movingImage) -%AUTOALIGNMOVINGTOREFERENCE Estimate and apply an integer translation. +%AUTOALIGNMOVINGTOREFERENCE Estimate and apply a rigid transform. % % Usage: % [alignedImage, transform, method] = ... @@ -8,27 +8,29 @@ % % Inputs: % referenceImage - Numeric grayscale or RGB reference image. Its first two -% dimensions define the output canvas and correlation size. -% movingImage - Numeric grayscale or RGB image to translate. +% dimensions define the output canvas. +% movingImage - Numeric grayscale or RGB image to register rigidly. % % Outputs: -% alignedImage - Original movingImage translated onto the reference canvas, -% with linear interpolation and zero fill. -% tformRigid - Three-by-three row-vector homogeneous translation transform, +% alignedImage - Original movingImage rotated and translated onto the +% reference canvas, with linear interpolation and zero fill. +% tformRigid - Three-by-three row-vector homogeneous rigid transform, % shown as transform in the usage syntax. -% method - Character vector identifying the fixed phase-correlation method. +% method - Character vector identifying the fixed coarse-to-fine method. % % Description: -% Each image is converted to normalized grayscale independently. For shift -% estimation only, moving grayscale data is resized to the reference size by -% nearest-neighbor sampling. Phase correlation returns a whole-pixel circular -% shift. Rotation, scale, deformation, repeated texture, and large nonoverlap -% can produce a poor fit. +% Each image is converted to normalized grayscale independently. The search +% covers -30 through +30 degrees at three-degree spacing and refines the best +% neighborhood at half-degree spacing. Each candidate evaluates a toolbox- +% free, zero-padded phase-correlation translation on a response-limited +% preview. The accepted rotation and translation are then applied to the +% original moving image. Scale and deformation are not estimated; repeated +% texture and large nonoverlap can still produce a poor fit. % % Failure Behavior: % The function does not assign a confidence score or reject an ambiguous -% phase-correlation peak; low-texture or repeated-pattern inputs can return a -% numerically valid but poor translation. Empty arrays, unsupported image +% registration peak; low-texture or repeated-pattern inputs can return a +% numerically valid but poor transform. Empty arrays, unsupported image % classes, or invalid channel shapes propagate image conversion/interpolation % errors. % @@ -47,11 +49,10 @@ fixedGray = normalizeGray(referenceImage); movingGray = normalizeGray(movingImage); - [rowShift, colShift] = estimateTranslation(fixedGray, movingGray); - tformRigid = [1 0 0; 0 1 0; colShift rowShift 1]; + tformRigid = estimateRigidTransform(fixedGray, movingGray); alignedImage = dic_preprocess.analysisRun.applyRigidTransform( ... referenceImage, movingImage, tformRigid); - method = 'toolbox-free phase-correlation translation registration'; + method = 'toolbox-free coarse-to-fine rigid phase-correlation registration'; end function gray = normalizeGray(imageData) @@ -73,28 +74,114 @@ end end -function [rowShift, colShift] = estimateTranslation(fixedGray, movingGray) - targetSize = size(fixedGray); - movingGray = resizeToMatch(movingGray, targetSize); - fixedGray = fixedGray - finiteMean(fixedGray); - movingGray = movingGray - finiteMean(movingGray); - fixedGray(~isfinite(fixedGray)) = 0; - movingGray(~isfinite(movingGray)) = 0; +function transform = estimateRigidTransform(fixedGray, movingGray) + % DIC camera repositioning is expected to be modest. Searching this + % bounded range and two resolution stages keep interactive registration + % responsive while restoring the rotation capability lost by the + % translation-only fallback. A 256-pixel preview bounds each candidate's + % work without changing the source-resolution output transform. + maximumExpectedRotationDegrees = 30; + coarseAngleStepDegrees = 3; + fineAngleStepDegrees = .5; + maximumPreviewDimension = 256; + fixedSize = [size(fixedGray, 1), size(fixedGray, 2)]; + movingSize = [size(movingGray, 1), size(movingGray, 2)]; + sampleStep = max(1, ceil(max([fixedSize, movingSize]) / ... + maximumPreviewDimension)); + fixedRows = 1:sampleStep:size(fixedGray, 1); + fixedCols = 1:sampleStep:size(fixedGray, 2); + fixedPreview = fixedGray(fixedRows, fixedCols); + fixedFeature = registrationFeature(fixedPreview); + coarseAngles = -maximumExpectedRotationDegrees: ... + coarseAngleStepDegrees:maximumExpectedRotationDegrees; + [bestTransform, bestAngle, bestScore] = bestCandidate( ... + coarseAngles, fixedGray, movingGray, fixedFeature, ... + fixedRows, fixedCols, sampleStep); + fineAngles = bestAngle + ... + (-coarseAngleStepDegrees:fineAngleStepDegrees:coarseAngleStepDegrees); + fineAngles = fineAngles(abs(fineAngles) <= maximumExpectedRotationDegrees); + [fineTransform, ~, fineScore] = bestCandidate( ... + fineAngles, fixedGray, movingGray, fixedFeature, ... + fixedRows, fixedCols, sampleStep); + if fineScore > bestScore + bestTransform = fineTransform; + end + transform = bestTransform; +end + +function [bestTransform, bestAngle, bestScore] = bestCandidate( ... + angles, fixedGray, movingGray, fixedFeature, ... + fixedRows, fixedCols, sampleStep) + fixedCenter = ([size(fixedGray, 2), size(fixedGray, 1)] + 1) / 2; + movingCenter = ([size(movingGray, 2), size(movingGray, 1)] + 1) / 2; + bestScore = -inf; + bestAngle = 0; + bestTransform = eye(3); + for angle = angles + radians = angle * pi / 180; + rotation = [cos(radians) sin(radians); ... + -sin(radians) cos(radians)]; + centerTranslation = fixedCenter - movingCenter * rotation; + centered = warpPreview( ... + movingGray, rotation, centerTranslation, fixedRows, fixedCols); + [rowShift, colShift] = estimateTranslation( ... + fixedFeature, registrationFeature(centered)); + translation = centerTranslation + ... + sampleStep * [colShift rowShift]; + warped = warpPreview( ... + movingGray, rotation, translation, fixedRows, fixedCols); + score = alignmentScore(fixedGray(fixedRows, fixedCols), warped); + if score > bestScore + bestScore = score; + bestAngle = angle; + bestTransform = [rotation [0; 0]; translation 1]; + end + end +end + +function preview = warpPreview(imageData, rotation, translation, rows, cols) + [xGrid, yGrid] = meshgrid(cols, rows); + source = ([xGrid(:), yGrid(:)] - translation) * rotation.'; + preview = interp2(double(imageData), ... + reshape(source(:, 1), size(xGrid)), ... + reshape(source(:, 2), size(yGrid)), 'linear', NaN); +end - spectrum = fft2(fixedGray) .* conj(fft2(movingGray)); +function feature = registrationFeature(imageData) + imageData(~isfinite(imageData)) = finiteMean(imageData); + horizontal = [diff(imageData, 1, 2), zeros(size(imageData, 1), 1)]; + vertical = [diff(imageData, 1, 1); zeros(1, size(imageData, 2))]; + feature = hypot(horizontal, vertical); +end + +function [rowShift, colShift] = estimateTranslation(fixedFeature, movingFeature) + fixedFeature = fixedFeature - finiteMean(fixedFeature); + movingFeature = movingFeature - finiteMean(movingFeature); + fixedFeature(~isfinite(fixedFeature)) = 0; + movingFeature(~isfinite(movingFeature)) = 0; + transformSize = 2 .* ... + [size(fixedFeature, 1), size(fixedFeature, 2)]; + spectrum = fft2(fixedFeature, transformSize(1), transformSize(2)) .* ... + conj(fft2(movingFeature, transformSize(1), transformSize(2))); magnitude = abs(spectrum); magnitude(magnitude == 0) = 1; - correlation = real(ifft2(spectrum ./ magnitude)); + % Retain part of the spectral amplitude so broad DIC texture contributes + % to the peak instead of letting weak periodic frequencies dominate it. + correlation = real(ifft2(spectrum ./ sqrt(magnitude))); + rowValues = 0:size(correlation, 1)-1; + colValues = 0:size(correlation, 2)-1; + rowValues(rowValues > size(correlation, 1) / 2) = ... + rowValues(rowValues > size(correlation, 1) / 2) - size(correlation, 1); + colValues(colValues > size(correlation, 2) / 2) = ... + colValues(colValues > size(correlation, 2) / 2) - size(correlation, 2); + allowedRows = abs(rowValues) <= floor(.45 * size(fixedFeature, 1)); + allowedCols = abs(colValues) <= floor(.45 * size(fixedFeature, 2)); + correlation(~allowedRows, :) = -inf; + correlation(:, ~allowedCols) = -inf; [~, idx] = max(correlation(:)); [peakRow, peakCol] = ind2sub(size(correlation), idx); - rowShift = peakRow - 1; - colShift = peakCol - 1; - if rowShift > floor(size(correlation, 1) / 2) - rowShift = rowShift - size(correlation, 1); - end - if colShift > floor(size(correlation, 2) / 2) - colShift = colShift - size(correlation, 2); - end + rowShift = rowValues(peakRow); + colShift = colValues(peakCol); end function value = finiteMean(imageData) @@ -106,22 +193,21 @@ end end -function imageOut = resizeToMatch(imageIn, targetSize) - if isequal(size(imageIn, 1), targetSize(1)) && ... - isequal(size(imageIn, 2), targetSize(2)) - imageOut = imageIn; +function score = alignmentScore(fixedImage, movingImage) + valid = isfinite(fixedImage) & isfinite(movingImage); + overlapFraction = nnz(valid) / numel(valid); + if overlapFraction < .2 + score = -inf; return; end - rowIdx = nearestIndices(size(imageIn, 1), targetSize(1)); - colIdx = nearestIndices(size(imageIn, 2), targetSize(2)); - imageOut = imageIn(rowIdx, colIdx, :); -end - -function idx = nearestIndices(inputLength, outputLength) - if outputLength <= 1 - idx = 1; + fixedValues = fixedImage(valid); + movingValues = movingImage(valid); + fixedValues = fixedValues - mean(fixedValues); + movingValues = movingValues - mean(movingValues); + denominator = norm(fixedValues) * norm(movingValues); + if denominator <= eps + score = -inf; return; end - positions = linspace(1, inputLength, outputLength); - idx = min(max(round(positions), 1), inputLength); + score = (fixedValues.' * movingValues) / denominator; end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m index f7455efa8..423ec5fa8 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m @@ -28,6 +28,8 @@ function drawOne(ax, model) background.Tag = backgroundTag(); axis(ax, 'image'); ax.YDir = 'reverse'; + ax.XLim = [.5 size(model.imageData, 2) + .5]; + ax.YLim = [.5 size(model.imageData, 1) + .5]; end delete(findobj(ax, 'Tag', overlayTag())); diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m index 9cf3a2a25..4102b7c36 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m @@ -24,7 +24,8 @@ .enabled("cancelCropRoi", cropping) ... .enabled("undoEdit", ~isempty(annotations.history)) ... .enabled("resetToOriginals", hasPair) ... - .renderPlot("preview", model) ... + .renderPlot("preview", model, ... + ViewRevision=cache.plotViewRevision) ... .pairedAnchors("matchPoints", ... {annotations.matchReferencePoints, ... annotations.matchMovingPoints}, ... @@ -41,6 +42,7 @@ "moving", axisModel(request.bottomImage, request.bottomTitle)); if state.session.workflow.mode == "crop" model.reference.rectangle = state.project.annotations.cropRect; + model.moving.rectangle = state.project.annotations.cropRect; elseif state.session.workflow.mode == "matching" model.reference.pointLabels = ... state.project.annotations.matchReferencePoints; diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m index 3982b89dc..072bf3159 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m @@ -2,8 +2,10 @@ function applicationState = rebuildCache(applicationState) %REBUILDCACHE Replay durable edit steps into transient working images. cache = applicationState.session.cache; +plotViewRevision = cache.plotViewRevision; applicationState.session.cache = ... dic_preprocess.analysisRun.replayEditSteps( ... cache.referenceImage, cache.movingImage, ... applicationState.project.annotations.editSteps); +applicationState.session.cache.plotViewRevision = plotViewRevision; end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/replayEditSteps.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/replayEditSteps.m index 5366c5950..0db191188 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/replayEditSteps.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/replayEditSteps.m @@ -9,7 +9,8 @@ "currentMovingImage", movingImage, ... "alignedImage", [], ... "cropReference", [], ... - "cropMoving", []); + "cropMoving", [], ... + "plotViewRevision", 0); for k = 1:numel(steps) step = steps(k); switch string(step.kind) diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m index 05aa7cb87..0344d61a8 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m @@ -2,6 +2,7 @@ function state = startPointMatching(state, ~) if dic_preprocess.sourceFiles.hasImagePair(state.session.cache) state.session.workflow.mode = "matching"; + state.project.parameters.previewMode = "Current moving image"; state.project.annotations.matchReferencePoints = zeros(0,2); state.project.annotations.matchMovingPoints = zeros(0,2); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m index 75d625e13..22bbdf7dd 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m @@ -25,6 +25,6 @@ .enabled("clearMaskBoundary", active && pointCount > 0) ... .enabled("clearMaskCanvas", active && ... ~isempty(annotations.maskImage)) ... - .pointSlots("maskPoints", annotations.maskPoints, ... + .anchorPath("maskPoints", annotations.maskPoints, ... ImageSize=imageSize, Enabled=active); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m index 421b71031..066e0563e 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m @@ -21,9 +21,9 @@ crop = labkit.app.interaction.rectangle("cropRectangle", ... @dic_preprocess.analysisRun.changeCropRectangle, Axis="reference", ... ViewportPolicy="preserve"); -maskPoints = labkit.app.interaction.pointSlots("maskPoints", ... +maskPoints = labkit.app.interaction.anchorPath("maskPoints", ... @dic_preprocess.maskEditing.changeBoundaryPoints, Axis="reference", ... - ViewportPolicy="preserve"); + Style=struct("closed", true), ViewportPolicy="preserve"); workspace = labkit.app.layout.workspace(labkit.app.layout.plotArea("preview", ... @dic_preprocess.analysisRun.drawPreview, ... AxisIds=["reference", "moving"], ... diff --git a/apps/dic/dic_preprocess/+dic_preprocess/definition.m b/apps/dic/dic_preprocess/+dic_preprocess/definition.m index a3c009104..f89d3767d 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/definition.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/definition.m @@ -4,8 +4,8 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_DICPreprocess_app", ... AppId="dic_preprocess", Title="DIC Image Preprocess", ... - DisplayName="DIC Preprocess", Family="DIC", AppVersion="1.7.1", ... - Updated="2026-07-30", Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... + DisplayName="DIC Preprocess", Family="DIC", AppVersion="1.7.2", ... + Updated="2026-08-03", Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=dic_preprocess.projectSpec(), CreateSession=@dic_preprocess.createSession, ... Workbench=dic_preprocess.workbench.buildLayout(), PresentWorkbench=@dic_preprocess.workbench.present, ... BuildSyntheticSample=@dic_preprocess.syntheticInputs.writeSamplePack); diff --git a/docs/apps/dic/dic-preprocess/README.md b/docs/apps/dic/dic-preprocess/README.md index feb4c3701..6b68d57c8 100644 --- a/docs/apps/dic/dic-preprocess/README.md +++ b/docs/apps/dic/dic-preprocess/README.md @@ -51,15 +51,18 @@ derived working pair and replays no edits. | ROI mask | current binary mask over the image domain | Changing preview mode does not change project data. Point placement, point -dragging, crop editing, mask editing, and applying an operation preserve the -current axes zoom. Use the plot **Fit** action when a full-image view is wanted. +dragging, crop editing, mask editing, and alignment preserve the current axes +zoom. Applying a crop fits both axes to the new pixel domain so the removed +image area does not remain as white plot margins. Use the plot **Fit** action +when a full-image view is otherwise wanted. ## Manual Point Matching -Press **Start point matching**. Click a feature in the reference image, then -click the same feature in the moving image. Repeat this reference/moving order -for at least two complete pairs. Numbered markers show correspondence and the -preview subtitle states which image expects the next point. +Press **Start point matching**. The preview switches to the current reference +and current moving images. Click a feature in the reference image, then click +the same feature in the moving image. Repeat this reference/moving order for at +least two complete pairs. Numbered markers show correspondence and the preview +subtitle states which image expects the next point. Drag an existing marker to refine it. **Undo point pair** removes the newest complete pair. **Cancel point matching** discards the pending point set without @@ -73,19 +76,24 @@ clustered points provide weak rotational leverage. ## Automatic Alignment -**Auto align current pair** runs the app-owned base-MATLAB registration path. -It returns the same aligned image and transform fields as manual alignment. -Automatic alignment is a starting estimate, not a guarantee of DIC-quality -correspondence. Always inspect the false-color overlay and prefer manual points -when the image has repeated texture, large occlusion, or weak contrast. +**Auto align current pair** runs the app-owned base-MATLAB rigid-registration +path. It searches rotations from -30 to +30 degrees, refines the best angle, +and estimates translation with zero-padded, amplitude-weighted phase +correlation. It returns the same aligned image and rigid-transform fields as +manual alignment. Automatic alignment is a starting estimate, not a guarantee +of DIC-quality correspondence. Always inspect the false-color overlay and +prefer manual points when rotation exceeds the search range or the image has +repeated texture, large occlusion, scale change, deformation, or weak contrast. ## Crop ROI **Start/reset crop ROI** creates a square rectangle constrained to the current -reference image. Drag the rectangle to move it and use its resize handles to -change its size. **Apply ROI crop** uses exactly the same integer image-domain -rectangle on the reference and aligned moving image. **Cancel ROI** exits the -editor without adding a crop step. +reference image and draws the same rectangle on the moving preview for direct +comparison. Drag the reference rectangle to move it and use its resize handles +to change its size. **Apply ROI crop** uses exactly the same integer +image-domain rectangle on the reference and aligned moving image, then fits +both axes to the cropped domain. **Cancel ROI** exits the editor without adding +a crop step. Applied crops change the coordinate domain for later operations. Undo the crop before reusing point coordinates defined on the larger image. diff --git a/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md b/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md new file mode 100644 index 000000000..2100c3e83 --- /dev/null +++ b/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md @@ -0,0 +1,76 @@ +# DIC preprocess restores interactive editing and rigid registration + +```labkit-change +id: LK-20260803-dic-preprocess-interaction-registration-repair +date: 2026-08-03 +sequence: 167 +type: fix +compatibility: compatible +component: `labkit_DICPreprocess_app` | `1.7.1 -> 1.7.2` +scope: DIC preprocess interaction repair +scope: Rigid image registration +``` + +## Context + +DIC Preprocess could leave manual matching on an overlay instead of the moving +image, showed a crop rectangle only on the reference preview, retained stale +plot limits after applying a crop, and failed to start mask editing because a +variable anchor list was supplied to the fixed point-slot interaction. Its +toolbox-free automatic path also estimated translation only, so ordinary +camera rotation could make the starting alignment inaccurate. + +## Decision and rationale + +Keep the repair App-local. Use the existing variable anchor-path interaction +for mask boundaries, make matching and crop preview transitions explicit, and +use the plot revision contract when the crop changes the image domain. Restore +automatic rigid behavior with a bounded coarse-to-fine rotation search and +amplitude-weighted, zero-padded phase correlation implemented in base MATLAB. + +## Changes + +- Manual point matching now selects the current moving-image preview. +- The active crop rectangle is rendered on both images, and applying the crop + fits both axes to the new pixel domain. +- Mask boundaries use a variable closed anchor path and can enter edit mode + from an empty boundary. +- Automatic alignment estimates rotation and translation over a response- + limited preview before applying the accepted transform at source resolution. + +## User and data impact + +Users can activate mask editing, compare the same crop on both images, and see +the cropped image without stale white margins. Automatic alignment is more +useful for camera motion that includes rotation. Existing project fields, +saved images, masks, coordinate conventions, and export schemas are unchanged. + +## Compatibility and migration + +The change is compatible with version-1 DIC Preprocess projects. No project +migration is required. Manual alignment remains a rigid rotation-and- +translation fit, and automatic alignment continues to return the same +three-by-three row-vector transform shape. + +## Validation + +Focused scientific evidence covers existing integer translation and a +controlled rotation-plus-translation case without optional Toolboxes. The +hidden-GUI workflow covers moving-preview selection, crop overlays on both +axes, fitted crop limits, successful mask activation, export, and project +restore. + +## Evidence + +- `labkittest.run(Owner="apps/dic/dic_preprocess/analysisrun", Contract="scientific")` +- `labkittest.run(Owner="apps/dic/dic_preprocess/workbench", Contract="presentation")` +- The privacy-safe diagnostic bundle reported + `labkit:app:runtime:InvalidPointSlotsValue` from mask activation. + +## Known limitations and follow-up + +Automatic alignment searches rotations from -30 to +30 degrees and does not +estimate scale, shear, or deformation. Repeated texture, weak contrast, large +occlusion, or limited overlap can still require manual matched points. Native +pointer feel and suitability for real DIC imagery remain manual review +boundaries. diff --git a/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m b/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m index f6dc4ed23..49e40149b 100644 --- a/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m +++ b/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m @@ -51,5 +51,30 @@ function alignsIntegerTranslationsWithoutAnOptionalToolbox(testCase) testCase.verifyEqual(transform, [1 0 0; 0 1 0; 3 -2 1]); testCase.verifySubstring(method, 'toolbox-free'); end + + function reducesControlledRotationAndTranslationWithoutAToolbox(testCase) + [x, y] = meshgrid(1:96, 1:80); + reference = sin(x / 4) + cos(y / 7) + ... + 2 * exp(-((x - 29).^2 + (y - 23).^2) / 90) + ... + 3 * exp(-((x - 68).^2 + (y - 57).^2) / 55); + angle = 7 * pi / 180; + rotation = [cos(angle) sin(angle); -sin(angle) cos(angle)]; + center = ([size(reference, 2), size(reference, 1)] + 1) / 2; + expected = [rotation [0; 0]; ... + center - center * rotation + [4 -3], 1]; + moving = dic_preprocess.analysisRun.applyRigidTransform( ... + reference, reference, inv(expected)); + + [aligned, transform, method] = ... + dic_preprocess.analysisRun.autoAlignMovingToReference( ... + reference, moving); + + initialError = norm(reference - moving, "fro"); + alignedError = norm(reference - aligned, "fro"); + testCase.verifyLessThan(alignedError, .45 * initialError); + testCase.verifyLessThan(norm(transform(1:2, 1:2) - ... + expected(1:2, 1:2), "fro"), .05); + testCase.verifySubstring(method, 'rigid'); + end end end diff --git a/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m b/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m index 1043b99f4..f88befc30 100644 --- a/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m +++ b/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m @@ -20,9 +20,25 @@ function alignsCropsExportsAndRestoresASyntheticPair(testCase) runtime.applyFileSelection("referenceFile", string(reference), 1); runtime.applyFileSelection("movingFile", string(moving), 1); + runtime.invokeAction("startPointMatching"); + testCase.verifyEqual( ... + runtime.State.project.parameters.previewMode, ... + "Current moving image"); runtime.invokeAction("autoAlign"); runtime.invokeAction("startCropRoi"); + referenceAxes = findall(figureValue, "Tag", "preview.reference"); + movingAxes = findall(figureValue, "Tag", "preview.moving"); + overlayTag = "labkitDicPreprocessPreviewOverlay"; + testCase.verifyNotEmpty(findall(referenceAxes, "Tag", overlayTag)); + testCase.verifyNotEmpty(findall(movingAxes, "Tag", overlayTag)); runtime.invokeAction("applyCropRoi"); + croppedSize = size(runtime.State.session.cache.currentReferenceImage); + testCase.verifyEqual(referenceAxes.XLim, ... + [.5 croppedSize(2) + .5], AbsTol=1e-12); + testCase.verifyEqual(referenceAxes.YLim, ... + [.5 croppedSize(1) + .5], AbsTol=1e-12); + runtime.invokeAction("startMaskEdit"); + testCase.verifyEqual(runtime.State.session.workflow.mode, "mask"); runtime.invokeAction("saveCurrentImages"); cache = runtime.State.session.cache; From 777c7b95156963e8fea5f5d8d32878b04b5d2e50 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 13:13:32 -0500 Subject: [PATCH 02/32] feat: simplify session diagnostics and automatic export --- .../MatlabPlatformAdapter.m | 21 ---- .../installUtilityMenus.m | 7 +- +labkit/+app/+internal/RuntimeKernel.m | 107 ++++++++++++++++-- .../+app/+internal/SessionDiagnosticBundle.m | 74 ++++-------- +labkit/+app/+internal/SessionEventStream.m | 4 + +labkit/+app/+internal/SessionLogProjection.m | 32 ++---- +labkit/+app/+internal/SessionLogViewer.m | 75 +++++------- +labkit/+app/version.m | 2 +- .agents/skills/labkit-test-planner/SKILL.md | 15 +++ .../scripts/runFocusedSpecs.m | 91 +++++++++++++++ docs/framework/guides/runtime.md | 58 +++++----- docs/getting-started/README.md | 4 + ...session-log-levels-and-automatic-export.md | 80 +++++++++++++ .../labkit/app/SessionDiagnosticBundleSpec.m | 24 ++-- .../labkit/app/SessionLogProjectionSpec.m | 52 +++++---- tests/specs/labkit/app/SessionLogViewerSpec.m | 83 +++++++++----- 16 files changed, 481 insertions(+), 248 deletions(-) create mode 100644 .agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m create mode 100644 docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m index 68ac3a613..48311c7a6 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -28,7 +28,6 @@ StartupPanel StartupLabel LogViewer - TraceCaptureMenu end methods (Access = { ... @@ -436,26 +435,6 @@ function openSessionLog(obj) obj.LogViewer.show(); end - function toggleTraceCapture(obj) - enabled = true; - if ~isempty(obj.TraceCaptureMenu) && ... - isvalid(obj.TraceCaptureMenu) - enabled = string(obj.TraceCaptureMenu.Checked) ~= "on"; - end - obj.Runtime.setTraceCapture(enabled); - if ~isempty(obj.TraceCaptureMenu) && ... - isvalid(obj.TraceCaptureMenu) - if enabled - obj.TraceCaptureMenu.Checked = "on"; - else - obj.TraceCaptureMenu.Checked = "off"; - end - end - if ~isempty(obj.LogViewer) && isvalid(obj.LogViewer) - obj.LogViewer.refresh(); - end - end - function handles = allAxes(obj) values = obj.Axes.values; if isempty(values) diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m index e54774d1d..5e8fdfb98 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m @@ -8,12 +8,7 @@ function installUtilityMenus(obj) Tag="labkitAppUtilitySessionLog", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.openSessionLog())); - obj.TraceCaptureMenu = uimenu( ... - diagnosticsMenu, Text="Trace Capture", Checked="off", ... - Tag="labkitAppUtilityTraceCapture", ... - MenuSelectedFcn=@(~, ~) obj.runUtility( ... - @() obj.toggleTraceCapture())); - uimenu(diagnosticsMenu, Text="Export Diagnostic Bundle...", ... + uimenu(diagnosticsMenu, Text="Export Diagnostic Bundle", ... Tag="labkitAppUtilityExportDiagnostics", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.Runtime.exportDiagnosticBundleInteractive())); diff --git a/+labkit/+app/+internal/RuntimeKernel.m b/+labkit/+app/+internal/RuntimeKernel.m index ecf4cc2d0..a93f3f28d 100644 --- a/+labkit/+app/+internal/RuntimeKernel.m +++ b/+labkit/+app/+internal/RuntimeKernel.m @@ -168,6 +168,10 @@ function failNextCommit(obj) snapshot = obj.Recorder.captureSnapshot(); end + function title = sessionLogTitle(obj) + title = obj.Application.DisplayName + " — Session Log"; + end + function token = subscribeDiagnostics(obj, callback) token = obj.Recorder.subscribe(callback); end @@ -200,21 +204,33 @@ function setTraceCapture(obj, enabled) function destination = exportDiagnosticBundleInteractive(obj) destination = ""; try - choice = obj.Context.chooseOutputFile( ... - {"*.zip", "Diagnostic bundle (*.zip)"}, ... - "labkit-diagnostics.zip"); - catch cause - destination = obj.exportDiagnosticTextFallback("", cause); - obj.alertDiagnosticTextFallback(destination); - return; - end - if ~choice.Cancelled - destination = obj.exportDiagnosticBundle(choice.Value); + automaticDestination = ... + obj.automaticDiagnosticDestination(); + destination = obj.exportDiagnosticBundle( ... + automaticDestination); if endsWith(destination, ".txt", ... IgnoreCase=true) obj.alertDiagnosticTextFallback(destination); + else + obj.Context.alert( ... + "Diagnostic bundle written to:" + newline + ... + string(destination), ... + "Diagnostic Bundle Exported"); end + return; + catch automaticFailure + fallbackName = diagnosticFallbackName( ... + obj.automaticDiagnosticFilename()); + end + choice = obj.Context.chooseOutputFile( ... + {"*.txt", "Diagnostic text fallback (*.txt)"}, ... + fallbackName); + if choice.Cancelled + return; end + destination = obj.exportDiagnosticTextFallback( ... + choice.Value, automaticFailure); + obj.alertDiagnosticTextFallback(destination); end function destination = exportDiagnosticTextFallback( ... @@ -851,10 +867,25 @@ function execute(obj, binding, payload) candidate = binding.UpdateState( ... previousState, payload, obj.Context); end + obj.Recorder.log( ... + "trace", "callback.state_updated", ... + "Callback state update completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); labkit.app.internal.RuntimeContractBoundary.validateState( ... obj.Application, candidate); + obj.Recorder.log( ... + "trace", "callback.state_validated", ... + "Callback state validation completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); view = obj.present(candidate); obj.Adapter.reconcile(previousPresentation, view); + obj.Recorder.log( ... + "trace", "callback.presentation_committed", ... + "Native presentation commit completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); obj.State = candidate; obj.Presentation = view; if isempty(obj.PendingDocumentMetadata) @@ -870,6 +901,11 @@ function execute(obj, binding, payload) obj.PendingDocumentMetadata = []; obj.Resources.clearScope("event"); obj.Recorder.finish(operation, "failed", "rolledBack", cause); + obj.Recorder.log( ... + "trace", "callback.rollback_completed", ... + "Callback rollback cleanup completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); failure = MException("labkit:app:runtime:ActionFailed", ... "Callback %s failed transactionally.", binding.Id); failure = addCause(failure, cause); @@ -889,13 +925,25 @@ function execute(obj, binding, payload) @(records, role) ... obj.presentationSourcePaths(records, role), ... obj.CurrentStatus); + obj.Recorder.log( ... + "trace", "presentation.runtime_prepared", ... + "Runtime presentation model prepared.", ... + Category="runtime.presentation", Audience="developer"); if isempty(obj.Application.PresentWorkbench) custom = labkit.app.view.Snapshot(); else custom = obj.Application.PresentWorkbench(state); end + obj.Recorder.log( ... + "trace", "presentation.app_prepared", ... + "App presentation overlay prepared.", ... + Category="runtime.presentation", Audience="developer"); view = view.overlayForRuntime(custom); obj.Application.validateViewSnapshot(view); + obj.Recorder.log( ... + "trace", "presentation.validated", ... + "Combined presentation validated.", ... + Category="runtime.presentation", Audience="developer"); obj.Recorder.finish( ... operation, "completed", "notApplicable", []); catch cause @@ -1025,6 +1073,32 @@ function markDocumentChanged(obj) obj.refreshWindowTitle(); end + function destination = automaticDiagnosticDestination(obj) + folder = diagnosticArtifactsFolder(); + if exist(char(folder), "dir") ~= 7 + [created, message] = mkdir(char(folder)); + if ~created + error("labkit:app:runtime:DiagnosticWriteFailed", ... + "Could not create the diagnostic artifacts folder: %s", ... + message); + end + end + destination = fullfile( ... + folder, obj.automaticDiagnosticFilename()); + end + + function filename = automaticDiagnosticFilename(obj) + appId = regexprep(lower(obj.Application.AppId), ... + "[^a-z0-9]+", "-"); + appId = regexprep(appId, "(^-+|-+$)", ""); + timestamp = string(datetime("now", TimeZone="UTC", ... + Format="yyyyMMdd-HHmmss")); + nonce = extractBefore( ... + string(java.util.UUID.randomUUID()), 9); + filename = "labkit-diagnostics-" + appId + "-" + ... + timestamp + "-" + nonce + ".zip"; + end + function refreshWindowTitle(obj) if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") obj.Adapter.setWindowTitle(obj.formattedWindowTitle()); @@ -1076,3 +1150,16 @@ function assertProjectStore(obj) failure = addCause(failure, failures{index}); end end + +function folder = diagnosticArtifactsFolder() +folder = string(fileparts(mfilename("fullpath"))); +for index = 1:3 + folder = string(fileparts(folder)); +end +folder = fullfile(folder, "artifacts", "diagnostics"); +end + +function filename = diagnosticFallbackName(zipFilename) +[~, name] = fileparts(string(zipFilename)); +filename = name + "-fallback.txt"; +end diff --git a/+labkit/+app/+internal/SessionDiagnosticBundle.m b/+labkit/+app/+internal/SessionDiagnosticBundle.m index 69aacdc26..6b6d8f8d6 100644 --- a/+labkit/+app/+internal/SessionDiagnosticBundle.m +++ b/+labkit/+app/+internal/SessionDiagnosticBundle.m @@ -53,26 +53,20 @@ end function destination = writeFallback(snapshot, preferredDestination) - % RuntimeKernel supplies sanitized in-memory records. Prefer a - % surviving selected folder, then MATLAB's writable temp folder. + % RuntimeKernel supplies sanitized in-memory records and an + % automatic or explicitly selected destination. snapshot = validateFallbackSnapshot(snapshot); - folders = fallbackFolders(preferredDestination); - failure = []; - for index = 1:numel(folders) - destination = availableFallbackPath(folders(index)); - try - writeText(destination, fallbackLines(snapshot)); - return; - catch cause - failure = cause; - end + destination = fallbackPath(preferredDestination); + folder = string(fileparts(destination)); + if strlength(folder) == 0 + folder = string(pwd); + destination = fullfile(folder, destination); end - if isempty(failure) + if exist(char(folder), "dir") ~= 7 error("labkit:app:runtime:DiagnosticWriteFailed", ... - "No diagnostic text fallback folder is available."); + "The diagnostic text fallback folder is unavailable."); end - error("labkit:app:runtime:DiagnosticWriteFailed", ... - "Could not write the diagnostic text fallback."); + writeText(destination, fallbackLines(snapshot)); end end end @@ -251,44 +245,20 @@ function validateRecord(record) end end -function folders = fallbackFolders(preferredDestination) -folders = strings(0, 1); -if ischar(preferredDestination) || ... - (isstring(preferredDestination) && isscalar(preferredDestination)) - preferredDestination = strip(string(preferredDestination)); - if strlength(preferredDestination) > 0 - folder = string(fileparts(preferredDestination)); - if strlength(folder) == 0 - folder = string(pwd); - end - if exist(char(folder), "dir") == 7 - folders(end + 1, 1) = folder; - end - end -end -temporaryFolder = string(tempdir); -if exist(char(temporaryFolder), "dir") == 7 - folders(end + 1, 1) = temporaryFolder; -end -folders = unique(folders, "stable"); -end - -function destination = availableFallbackPath(folder) -destination = fullfile(folder, "labkit-diagnostics-fallback.txt"); -if exist(char(destination), "file") ~= 2 && ... - exist(char(destination), "dir") ~= 7 - return; +function destination = fallbackPath(preferredDestination) +if ~(ischar(preferredDestination) || ... + (isstring(preferredDestination) && isscalar(preferredDestination))) || ... + strlength(strip(string(preferredDestination))) == 0 + error("labkit:app:contract:InvalidValue", ... + "Diagnostic text fallback destination must be nonempty scalar text."); end -for index = 2:1000 - candidate = fullfile(folder, ... - "labkit-diagnostics-fallback-" + string(index) + ".txt"); - if exist(char(candidate), "file") ~= 2 && ... - exist(char(candidate), "dir") ~= 7 - destination = candidate; - return; - end +preferredDestination = string(preferredDestination); +[folder, name, extension] = fileparts(preferredDestination); +if strcmpi(extension, ".txt") + destination = preferredDestination; +else + destination = fullfile(folder, name + "-fallback.txt"); end -destination = string(tempname(char(folder))) + ".txt"; end function value = timeline(events) diff --git a/+labkit/+app/+internal/SessionEventStream.m b/+labkit/+app/+internal/SessionEventStream.m index fe087c60c..1419ff5f0 100644 --- a/+labkit/+app/+internal/SessionEventStream.m +++ b/+labkit/+app/+internal/SessionEventStream.m @@ -181,6 +181,10 @@ function log(obj, severity, eventName, message, varargin) end record.exception = exception; obj.retain(record); + if any(severity == ["error", "critical"]) && ... + ~obj.TraceEnabled + obj.setTraceEnabled(true); + end end function records = records(obj) diff --git a/+labkit/+app/+internal/SessionLogProjection.m b/+labkit/+app/+internal/SessionLogProjection.m index 40d5dc40d..fb36dd5b3 100644 --- a/+labkit/+app/+internal/SessionLogProjection.m +++ b/+labkit/+app/+internal/SessionLogProjection.m @@ -16,8 +16,7 @@ ExpiredSegmentCount (1, 1) double = 0 DegradationReason (1, 1) string = "" ClearedThroughSequence (1, 1) double = 0 - LevelFilter (1, 1) string = "default" - AudienceFilter (1, 1) string = "default" + LevelFilter (1, 1) string = "trace" CategoryFilter (1, 1) string = "" RootActionFilter (1, 1) string = "" SearchText (1, 1) string = "" @@ -65,16 +64,11 @@ function append(obj, record) function setFilters(obj, varargin) options = labkit.app.internal.OptionParser.parse( ... "SessionLogProjection.setFilters", ... - ["Level", "Audience", "Category", "RootAction", "Search"], ... + ["Level", "Category", "RootAction", "Search"], ... varargin{:}); if isfield(options, "Level") obj.LevelFilter = oneOf(options.Level, ... - ["default", "trace", "debug", "info", ... - "warning", "error", "critical"], "Level"); - end - if isfield(options, "Audience") - obj.AudienceFilter = oneOf(options.Audience, ... - ["default", "all", "user", "developer"], "Audience"); + ["trace", "debug", "user"], "Level"); end if isfield(options, "Category") obj.CategoryFilter = optionalText( ... @@ -109,6 +103,7 @@ function clearView(obj) "categories", choices(string({obj.Events.category})), ... "rootActions", rootActionIds, ... "rootActionLabels", rootActionLabels, ... + "traceEnabled", obj.TraceEnabled, ... "notices", obj.notices(), ... "clearedThroughSequence", obj.ClearedThroughSequence); end @@ -135,22 +130,13 @@ function clearView(obj) keep = sequence > obj.ClearedThroughSequence; levels = lower(string({selected.severity})); audiences = lower(string({selected.audience})); - if obj.LevelFilter == "default" - rank = severityRank(levels); - keep = keep & ((audiences == "user" & rank >= 3) | ... - rank >= 4); + if obj.LevelFilter == "user" + keep = keep & audiences == "user" & ... + severityRank(levels) >= severityRank("info"); else keep = keep & severityRank(levels) >= ... severityRank(obj.LevelFilter); end - if obj.AudienceFilter == "user" - keep = keep & audiences == "user"; - elseif obj.AudienceFilter == "developer" - keep = keep & audiences == "developer"; - elseif obj.AudienceFilter == "default" - rank = severityRank(levels); - keep = keep & (audiences == "user" | rank >= 4); - end if strlength(obj.CategoryFilter) > 0 keep = keep & ... string({selected.category}) == obj.CategoryFilter; @@ -174,8 +160,8 @@ function clearView(obj) value = strings(0, 1); if ~obj.TraceEnabled value(end + 1, 1) = ... - "TRACE detail is off; DEBUG lifecycle and all warnings and errors " + ... - "are still captured. Earlier TRACE detail is unavailable."; + "TRACE capture starts automatically after the first ERROR; " + ... + "DEBUG and higher detail is complete so far."; end if obj.InMemoryTruncated value(end + 1, 1) = ... diff --git a/+labkit/+app/+internal/SessionLogViewer.m b/+labkit/+app/+internal/SessionLogViewer.m index ae2bffc5e..ff5aa21da 100644 --- a/+labkit/+app/+internal/SessionLogViewer.m +++ b/+labkit/+app/+internal/SessionLogViewer.m @@ -13,15 +13,13 @@ NoticeLabel SearchField LevelFilter - AudienceFilter CategoryFilter RootFilter - FollowButton + TraceButton CopyButton EventTable DetailArea VisibleSequences (1, :) double = zeros(1, 0) - FollowLatest (1, 1) logical = true Closed (1, 1) logical = false end @@ -99,7 +97,7 @@ function delete(obj) function createFigure(obj) obj.Figure = uifigure( ... Visible="off", ... - Name="LabKit Session Log", ... + Name=char(obj.Runtime.sessionLogTitle()), ... Position=viewerPosition(), ... AutoResizeChildren="off", ... CloseRequestFcn=@(~, ~) obj.close(), ... @@ -116,9 +114,9 @@ function createFigure(obj) Tag="labkitSessionLogSummary"); obj.SummaryLabel.Layout.Row = 1; - filters = uigridlayout(root, [2 8], ... + filters = uigridlayout(root, [2 4], ... RowHeight={30, 30}, ... - ColumnWidth={55, "1x", 45, 110, 40, 155, 70, 90}, ... + ColumnWidth={55, "1x", 50, 170}, ... Padding=[0 0 0 0], RowSpacing=4, ColumnSpacing=5); filters.Layout.Row = 2; label = uilabel(filters, Text="Search"); @@ -133,32 +131,15 @@ function createFigure(obj) label.Layout.Row = 1; label.Layout.Column = 3; obj.LevelFilter = uidropdown(filters, ... - Items=["Default", "TRACE+", "DEBUG+", "INFO+", ... - "WARNING+", "ERROR+", "CRITICAL"], ... - ItemsData=["default", "trace", "debug", "info", ... - "warning", "error", "critical"], ... + Items=["Full TRACE", "DEBUG", "User"], ... + ItemsData=["trace", "debug", "user"], ... + Tooltip=["Full TRACE shows every retained detail; DEBUG " + ... + "hides trace stages; User shows user-facing INFO and above."], ... ValueChangedFcn=@(~, ~) obj.applyFilters(), ... Tag="labkitSessionLogLevel"); - obj.LevelFilter.Value = "default"; + obj.LevelFilter.Value = "trace"; obj.LevelFilter.Layout.Row = 1; obj.LevelFilter.Layout.Column = 4; - label = uilabel(filters, Text="View", ... - Tooltip="Choose user workflow events, developer internals, or both.", ... - Tag="labkitSessionLogAudienceLabel"); - label.Layout.Row = 1; - label.Layout.Column = 5; - obj.AudienceFilter = uidropdown(filters, ... - Items=["Useful (default)", "Everything", ... - "User workflow", "Developer details"], ... - ItemsData=["default", "all", "user", "developer"], ... - Tooltip=[ ... - "Useful shows user events plus developer warnings and errors. " ... - "Developer details requires DEBUG+ to show normal callback boundaries."], ... - ValueChangedFcn=@(~, ~) obj.applyFilters(), ... - Tag="labkitSessionLogAudience"); - obj.AudienceFilter.Value = "default"; - obj.AudienceFilter.Layout.Row = 1; - obj.AudienceFilter.Layout.Column = 6; label = uilabel(filters, Text="Area"); label.Layout.Row = 2; label.Layout.Column = 1; @@ -180,21 +161,19 @@ function createFigure(obj) Tag="labkitSessionLogRoot"); obj.RootFilter.Value = ""; obj.RootFilter.Layout.Row = 2; - obj.RootFilter.Layout.Column = [4 8]; - obj.FollowButton = uibutton(filters, ... - Text="Pause follow", ... - ButtonPushedFcn=@(~, ~) obj.toggleFollow(), ... - Tag="labkitSessionLogFollow"); - obj.FollowButton.Layout.Row = 1; - obj.FollowButton.Layout.Column = [7 8]; + obj.RootFilter.Layout.Column = 4; - noticeGrid = uigridlayout(root, [1 5], ... - ColumnWidth={"1x", 80, 90, 110, 150}, ... + noticeGrid = uigridlayout(root, [1 6], ... + ColumnWidth={"1x", 100, 80, 90, 110, 175}, ... Padding=[0 0 0 0], ColumnSpacing=6); noticeGrid.Layout.Row = 3; obj.NoticeLabel = uilabel(noticeGrid, ... Text="", FontColor=[0.45 0.25 0], ... Tag="labkitSessionLogNotices"); + obj.TraceButton = uibutton(noticeGrid, ... + Text="Enable TRACE", ... + ButtonPushedFcn=@(~, ~) obj.toggleTraceCapture(), ... + Tag="labkitSessionLogTraceCapture"); uibutton(noticeGrid, Text="Refresh", ... ButtonPushedFcn=@(~, ~) obj.refresh(), ... Tag="labkitSessionLogRefresh"); @@ -206,7 +185,7 @@ function createFigure(obj) ButtonPushedFcn=@(~, ~) obj.copyDetails(), ... Tag="labkitSessionLogCopy"); uibutton(noticeGrid, ... - Text="Export diagnostic ZIP", ... + Text="Export Diagnostic Bundle", ... ButtonPushedFcn=@(~, ~) ... obj.Runtime.exportDiagnosticBundleInteractive(), ... Tag="labkitSessionLogExport"); @@ -243,7 +222,6 @@ function acceptRecord(obj, record) function applyFilters(obj) obj.Projection.setFilters( ... Level=string(obj.LevelFilter.Value), ... - Audience=string(obj.AudienceFilter.Value), ... Category=string(obj.CategoryFilter.Value), ... RootAction=string(obj.RootFilter.Value), ... Search=string(obj.SearchField.Value)); @@ -258,14 +236,10 @@ function clearView(obj) obj.refreshView(); end - function toggleFollow(obj) - obj.FollowLatest = ~obj.FollowLatest; - if obj.FollowLatest - obj.FollowButton.Text = "Pause follow"; - obj.followLatest(); - else - obj.FollowButton.Text = "Follow latest"; - end + function toggleTraceCapture(obj) + snapshot = obj.Runtime.diagnosticSnapshot(); + obj.Runtime.setTraceCapture(~snapshot.traceEnabled); + obj.refresh(); end function refreshView(obj, incremental) @@ -276,6 +250,11 @@ function refreshView(obj, incremental) return; end projection = obj.Projection.view(); + if projection.traceEnabled + obj.TraceButton.Text = "Disable TRACE"; + else + obj.TraceButton.Text = "Enable TRACE"; + end obj.updateChoices( ... obj.CategoryFilter, projection.categories); obj.updateChoices(obj.RootFilter, projection.rootActions, ... @@ -309,7 +288,7 @@ function refreshView(obj, incremental) projection.notices, " | ")); obj.NoticeLabel.FontColor = [0.55 0.28 0]; end - if obj.FollowLatest && (appended || ~incremental) + if appended || ~incremental obj.followLatest(); end end diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index f79f41ac3..3e0d1b954 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -30,6 +30,6 @@ % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "2.1.0", ">=2 <3", "stable", ... + "app", "2.2.0", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/.agents/skills/labkit-test-planner/SKILL.md b/.agents/skills/labkit-test-planner/SKILL.md index 92f97b5ef..9f563f33a 100644 --- a/.agents/skills/labkit-test-planner/SKILL.md +++ b/.agents/skills/labkit-test-planner/SKILL.md @@ -35,6 +35,21 @@ For focused MATLAB execution, add `tests` to the path and call changed `projectSpec.m` must explain to nonempty App-owned `persistence` evidence even when an end-to-end save/restore workflow also passes. +If `explain` shows that a framework source shares an intentionally broad owner +and `labkittest.run(File=...)` would expand a narrow iteration into that whole +owner, report the selected identity count before executing. For a user-requested +narrow iteration, run only the already identified owning specification files +with `scripts/runFocusedSpecs.m`; this helper establishes the repository and +test paths and rejects paths outside `tests/specs`. It is an iteration tool, +not a substitute for missing catalog evidence, `changedFast`, or CI. + +```matlab +addpath("/absolute/repo/.agents/skills/labkit-test-planner/scripts") +runFocusedSpecs([ ... + "tests/specs/labkit/app/SessionLogProjectionSpec.m" + "tests/specs/labkit/app/SessionDiagnosticBundleSpec.m"]); +``` + ## Choose Evidence Use the smallest behavior that proves the change: diff --git a/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m b/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m new file mode 100644 index 000000000..f82081272 --- /dev/null +++ b/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m @@ -0,0 +1,91 @@ +function results = runFocusedSpecs(specFiles) +%RUNFOCUSEDSPECS Run explicitly selected LabKit specification files. +% This agent-only helper owns repository path setup for narrow iteration. It +% accepts only existing MATLAB specifications beneath tests/specs and fails +% the MATLAB process when any selected identity fails or is incomplete. + + if ischar(specFiles) + specFiles = string(specFiles); + elseif iscell(specFiles) + specFiles = string(specFiles); + end + if ~(isstring(specFiles) && ~isempty(specFiles) && ... + all(~ismissing(specFiles)) && ... + all(strlength(strip(specFiles)) > 0)) + error("labkit:test:InvalidFocusedSpecs", ... + "Focused specification files must be nonempty text."); + end + specFiles = specFiles(:); + repoRoot = repositoryRoot(); + specsRoot = string(fullfile(repoRoot, "tests", "specs")); + addpath(char(repoRoot), "-begin"); + addpath(char(fullfile(repoRoot, "tests")), "-begin"); + + selected = cell(numel(specFiles), 1); + selectedPaths = strings(numel(specFiles), 1); + for index = 1:numel(specFiles) + filepath = validatedSpecPath( ... + repoRoot, specsRoot, specFiles(index)); + selectedPaths(index) = filepath; + selected{index} = ... + matlab.unittest.TestSuite.fromFile(char(filepath)); + end + suite = [selected{:}]; + environmentCleanup = configureEnvironment(selectedPaths); + fprintf("LabKit focused specifications: %d identities from %d file(s).\n", ... + numel(suite), numel(specFiles)); + results = run(suite); + disp(table(results)); + assertSuccess(results); + clear environmentCleanup +end + +function cleanup = configureEnvironment(paths) +hasHiddenGui = false; +for path = paths.' + source = string(fileread(path)); + if contains(source, "Env:path-isolated") + error("labkit:test:InvalidFocusedSpecs", ... + "Path-isolated specifications must run through labkittest.run."); + end + hasHiddenGui = hasHiddenGui || contains(source, "Env:hidden-gui"); +end +previous = getenv("LABKIT_GUI_TEST_MODE"); +cleanup = onCleanup(@() setenv("LABKIT_GUI_TEST_MODE", previous)); +if hasHiddenGui + setenv("LABKIT_GUI_TEST_MODE", "hidden"); +end +end + +function root = repositoryRoot() +root = string(fileparts(mfilename("fullpath"))); +for index = 1:4 + root = string(fileparts(root)); +end +end + +function filepath = validatedSpecPath(repoRoot, specsRoot, value) +value = strip(string(value)); +if contains(replace(value, "\\", "/"), "../") || ... + endsWith(replace(value, "\\", "/"), "/..") + error("labkit:test:InvalidFocusedSpecs", ... + "Focused specification paths cannot traverse parent folders."); +end +if isAbsolutePath(value) + filepath = value; +else + filepath = fullfile(repoRoot, value); +end +filepath = string(filepath); +prefix = specsRoot + filesep; +if ~(startsWith(filepath, prefix) && endsWith(filepath, ".m") && ... + isfile(filepath)) + error("labkit:test:InvalidFocusedSpecs", ... + "Focused specification must be an existing .m file under tests/specs."); +end +end + +function tf = isAbsolutePath(value) +tf = startsWith(value, filesep) || ... + ~isempty(regexp(char(value), '^[A-Za-z]:[\\/]', 'once')); +end diff --git a/docs/framework/guides/runtime.md b/docs/framework/guides/runtime.md index 4aae3b0ee..a9f9d363b 100644 --- a/docs/framework/guides/runtime.md +++ b/docs/framework/guides/runtime.md @@ -288,36 +288,34 @@ and safe for display. Pass caught exceptions through the dedicated `Exception` option instead of copying stack, path, identifier, or scientific data into free text. -The App's **Tools > Diagnostics** menu opens the live session viewer, enables -more detailed trace capture for future activity, and exports a diagnostic -bundle from the same session history. Enabling trace does not restart the App -or reconstruct earlier detail. Journal degradation is itself exposed in the -surviving in-memory stream; logging failures never alter callback transaction -semantics or scientific results. Native open and save dialogs normalize their -file filters to MATLAB character-cell tables before calling the platform -dialog, including the diagnostic ZIP destination on Windows releases. -If the save dialog, staging, ZIP creation, or final publish step fails, -Runtime writes the surviving privacy-safe records as one plain-text diagnostic -fallback. It first uses the selected destination folder when that folder is -available and otherwise uses MATLAB's user-writable temporary folder; the -failure alert reports the complete fallback path. - -The viewer's **View** filter describes intended readers, not access control. -**Useful** shows user-workflow events plus developer warnings and failures; -**User workflow**, **Developer details**, and **Everything** select the two -event audiences explicitly. The **Action** filter groups a top-level user or -lifecycle action with its nested callback, presentation, dialog, resource, and -transaction records. Its readable label includes time and a semantic message -while retaining the stable `op-*` correlation identifier for exported -diagnostics. - -TRACE capture is independent of ordinary failure capture. With TRACE off, -Runtime still retains DEBUG lifecycle/callback start and completion boundaries -and all INFO, WARNING, ERROR, and CRITICAL events. The default viewer hides -normal developer DEBUG/INFO records; selecting **DEBUG+** and **Developer -details** reveals earlier retained callback boundaries. A callback exception -is recorded as an ERROR with `failed` operation result, rollback disposition, -safe exception identifier, and sanitized function stack. +The App's **Tools > Diagnostics** menu opens the live session viewer and exports +a diagnostic bundle from the same session history. Each viewer title names the +App that owns the session. Its single **Level** selector has three modes: +**Full TRACE** displays every retained record, **DEBUG** hides trace-only +stages, and **User** shows user-audience INFO and higher events. Full TRACE is +the default view; it does not manufacture detail that was not captured. The +**Action** filter groups a top-level user or lifecycle action with its nested +callback, presentation, dialog, resource, and transaction records. + +Runtime initially captures DEBUG and higher records to bound ordinary-session +cost. The first ERROR or CRITICAL event automatically enables TRACE for later +activity. The viewer also provides an explicit **Enable TRACE** / **Disable +TRACE** control when a user needs detailed capture before an error. TRACE adds +callback state-update and validation stages, App/runtime presentation stages, +native presentation commit, and post-failure rollback cleanup; DEBUG retains +operation start and terminal boundaries. Enabling TRACE never reconstructs +earlier detail. + +**Export Diagnostic Bundle** writes directly to ignored +`artifacts/diagnostics/` with a generated App-specific, timestamped, unique ZIP +name. If ZIP staging or publication fails, Runtime writes a generated text +fallback beside that ZIP. Only when automatic output cannot be written does it +ask for another location, with the generated fallback filename already filled +in. The success or fallback alert reports the complete destination path. +Journal degradation remains visible in the surviving in-memory stream; logging +failures never alter callback transaction semantics or scientific results. +A callback exception is recorded as an ERROR with `failed` operation result, +rollback disposition, safe exception identifier, and sanitized function stack. Runtime close is also an instrumented lifecycle operation. Resource and native adapter cleanup continue independently; a cleanup exception is retained and diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index e33d2ba04..4729c5d57 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -66,6 +66,10 @@ Every current LabKit app exposes one top-level **Tools** menu: document. - **Tools > Project State > Load State...** opens a compatible project document. +- **Tools > Diagnostics > Open Session Log...** opens the current App's named + live log with Full TRACE, DEBUG, and User views. +- **Tools > Diagnostics > Export Diagnostic Bundle** writes an automatically + named privacy-safe ZIP beneath `artifacts/diagnostics/`. State files preserve app projects. They are different from exported result files and from ignored diagnostic manifests under `artifacts/diagnostics/`. diff --git a/docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md b/docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md new file mode 100644 index 000000000..6fd42defd --- /dev/null +++ b/docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md @@ -0,0 +1,80 @@ +# Session logging gains coherent detail levels and automatic export + +```labkit-change +id: LK-20260803-session-log-levels-and-automatic-export +date: 2026-08-03 +sequence: 168 +type: feat +compatibility: compatible +component: `labkit.app` | `2.1.0 -> 2.2.0` +scope: Session Log detail levels +scope: Automatic diagnostic export +``` + +## Context + +The standard Session Log combined a severity selector with a second audience +view, presented an unexplained Default choice, and exposed a pause-follow +control that did not improve diagnosis. TRACE normally contained no more useful +detail than DEBUG because capture was off and Runtime emitted few trace stages. +Diagnostic ZIP export also asked for a destination before every attempt. + +## Decision and rationale + +Use one three-level display contract while keeping capture cost independent of +the selected view. Retain DEBUG and higher during ordinary operation, enable +TRACE automatically after the first error, and keep an explicit capture toggle +inside the owning log window. Give TRACE distinct transaction and presentation +stage records. Treat the repository artifacts area as the first diagnostic +destination and ask for another location only after automatic recovery fails. + +## Changes + +- The viewer now offers Full TRACE, DEBUG, and User levels, defaults to Full + TRACE, follows new records continuously, and removes the duplicate View and + pause-follow controls. +- Each viewer title names its owning App, and manual TRACE capture lives in the + viewer instead of the App Tools menu. +- ERROR and CRITICAL records enable TRACE capture for later activity; trace + records now distinguish state update, validation, presentation, native + commit, and rollback cleanup stages. +- Diagnostic export generates a unique App-specific filename beneath + `artifacts/diagnostics/`; its text fallback uses the same base name, and a + prefilled save dialog appears only when automatic output fails. + +## User and data impact + +Users can distinguish concurrent App logs, select a meaningful amount of detail +with one control, and export diagnostics without choosing a path. Diagnostic +contents remain limited to validated privacy-safe Runtime records. Projects, +inputs, results, paths, filenames, images, and screenshots remain excluded. + +## Compatibility and migration + +The App SDK change is compatible with existing version-2 App requirements and +does not change callback logging syntax, canonical event fields, projects, or +results. Existing App definitions require no migration. The removed Tools-menu +TRACE item was a Runtime utility, not an App-facing API; the same manual ability +is available in the Session Log window. + +## Validation + +Focused headless specifications cover three-level projection, automatic trace +activation, distinct trace stages, generated ZIP and fallback names, and the +privacy boundary. Hidden-GUI specifications cover App-specific titles, the +single level selector, viewer-local TRACE control, continuous follow, removed +duplicate controls, and exports from both entry points. + +## Evidence + +- `labkittest.run(File="+labkit/+app/+internal/SessionLogProjection.m")` +- `labkittest.run(File="+labkit/+app/+internal/SessionLogViewer.m")` +- `labkittest.run(File="+labkit/+app/+internal/SessionDiagnosticBundle.m")` +- Deterministic documentation generation and authored-link validation. + +## Known limitations and follow-up + +TRACE cannot reconstruct stages that occurred before capture was enabled. A +process termination before Runtime records a terminal event still leaves only +the last successfully retained boundary. Native visual density and dialog feel +remain manual review boundaries. diff --git a/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m index 9e66d6d71..f828d2eb7 100644 --- a/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m +++ b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m @@ -89,7 +89,7 @@ function fallsBackToSafeMemoryWhenTheJournalIsUnavailable(testCase) clear cleanup end - function zipFailureWritesOneReadableTextFallback(testCase) + function writesOneReadableTextFallbackBesideTheAutomaticZip(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; definition = bundleDefinition(); @@ -98,8 +98,11 @@ function zipFailureWritesOneReadableTextFallback(testCase) cleanup = onCleanup(@() runtime.close()); runtime.invokeAction("run"); - destination = runtime.exportDiagnosticBundle(fullfile( ... - folder, "unavailable", "diagnostics.zip")); + destination = runtime.exportDiagnosticTextFallback( ... + fullfile(folder, "diagnostics.zip"), ... + MException( ... + "labkit:app:runtime:DiagnosticWriteFailed", ... + "Synthetic ZIP failure.")); fileCleanup = onCleanup(@() deleteIfFile(destination)); fallback = string(fileread(destination)); @@ -110,7 +113,7 @@ function zipFailureWritesOneReadableTextFallback(testCase) testCase.verifyTrue(contains( ... fallback, "analysis.failed")); testCase.verifyTrue(contains( ... - fallback, "diagnostics.bundle_exported.failed")); + fallback, "diagnostics.text_fallback.started")); testCase.verifyTrue(contains(fallback, ... "labkit:app:runtime:DiagnosticWriteFailed")); testCase.verifyFalse(contains(fallback, string(folder))); @@ -119,7 +122,7 @@ function zipFailureWritesOneReadableTextFallback(testCase) clear fileCleanup cleanup end - function saveDialogFailureStillWritesTheTextFallback(testCase) + function automaticExportUsesArtifactsAndDoesNotAskForAPath(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; definition = bundleDefinition(); @@ -132,12 +135,15 @@ function saveDialogFailureStillWritesTheTextFallback(testCase) destination = runtime.exportDiagnosticBundleInteractive(); fileCleanup = onCleanup(@() deleteIfFile(destination)); - fallback = string(fileread(destination)); testCase.verifyTrue(isfile(destination)); - testCase.verifyTrue(endsWith(destination, ".txt")); - testCase.verifyTrue(contains( ... - fallback, "labkit:test:OutputDialogFailure")); + testCase.verifyTrue(endsWith(destination, ".zip")); + testCase.verifyTrue(contains(destination, ... + fullfile("artifacts", "diagnostics"))); + [~, filename, extension] = fileparts(destination); + testCase.verifyTrue(startsWith( ... + string(filename) + string(extension), ... + "labkit-diagnostics-probe-diagnostic-bundle-")); clear fileCleanup cleanup end end diff --git a/tests/specs/labkit/app/SessionLogProjectionSpec.m b/tests/specs/labkit/app/SessionLogProjectionSpec.m index 6052ea274..536375255 100644 --- a/tests/specs/labkit/app/SessionLogProjectionSpec.m +++ b/tests/specs/labkit/app/SessionLogProjectionSpec.m @@ -2,7 +2,7 @@ % SESSIONLOGPROJECTIONSPEC Invariant: standard viewer filtering and clear-view semantics preserve canonical session history. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) - function filtersTheDefaultViewWithoutChangingCanonicalHistory(testCase) + function filtersThreeLevelsWithoutChangingCanonicalHistory(testCase) runtime = projectionRuntime(testCase); cleanup = onCleanup(@() runtime.close()); runtime.invokeAction("run"); @@ -11,9 +11,8 @@ function filtersTheDefaultViewWithoutChangingCanonicalHistory(testCase) runtime.diagnosticSnapshot()); defaultView = projection.view(); - testCase.verifyEqual( ... - sort(string(defaultView.rows.Level)), ... - ["INFO"; "WARNING"]); + testCase.verifyTrue(any( ... + defaultView.rows.Level == "DEBUG")); testCase.verifyTrue(any( ... defaultView.rows.Message == ... "Synthetic analysis completed.")); @@ -30,7 +29,7 @@ function filtersTheDefaultViewWithoutChangingCanonicalHistory(testCase) "Synthetic analysis completed."))); projection.setFilters( ... - Level="debug", Audience="all", ... + Level="debug", ... Category="app.probe.log-projection.analysis", ... Search="branch"); filtered = projection.view(); @@ -47,34 +46,38 @@ function filtersTheDefaultViewWithoutChangingCanonicalHistory(testCase) testCase.verifyEqual(runtime.diagnosticEvents(), before); projection.setFilters( ... - Level="default", Audience="default", ... + Level="trace", ... Category="", RootAction="", Search=""); runtime.invokeAction("run"); projection.update(runtime.diagnosticSnapshot()); afterClear = projection.view(); - testCase.verifyEqual(height(afterClear.rows), 2); + testCase.verifyGreaterThan(height(afterClear.rows), 2); testCase.verifyGreaterThan( ... min(afterClear.rows.Sequence), max([before.sequence])); clear cleanup end - function togglesTraceCaptureAndIsolatesAFailingConsumer(testCase) + function enablesTraceAfterErrorAndIsolatesAFailingConsumer(testCase) runtime = projectionRuntime(testCase); cleanup = onCleanup(@() runtime.close()); token = runtime.subscribeDiagnostics(@failConsumer); - runtime.invokeAction("run"); - records = runtime.diagnosticEvents(); - names = string({records.eventName}); - testCase.verifyFalse(any(names == "analysis.trace_step")); - - runtime.setTraceCapture(true); runtime.invokeAction("run"); snapshot = runtime.diagnosticSnapshot(); names = string({snapshot.events.eventName}); testCase.verifyTrue(snapshot.traceEnabled); testCase.verifyTrue(any(names == "trace.capture_enabled")); - testCase.verifyTrue(any(names == "analysis.trace_step")); + testCase.verifyFalse(any(names == "analysis.trace_before_error")); + testCase.verifyTrue(any(names == "analysis.trace_after_error")); + + runtime.invokeAction("run"); + records = runtime.diagnosticEvents(); + names = string({records.eventName}); + testCase.verifyTrue(any(names == "callback.state_updated")); + testCase.verifyTrue(any( ... + names == "presentation.runtime_prepared")); + testCase.verifyTrue(any( ... + names == "callback.presentation_committed")); runtime.unsubscribeDiagnostics(token); runtime.setTraceCapture(false); @@ -99,10 +102,7 @@ function reportsEveryCaptureAndRetentionLimitation(testCase) notices = projection.view().notices; testCase.verifyTrue(any(contains(notices, "TRACE"))); - testCase.verifyTrue(any(contains(notices, ... - "DEBUG lifecycle"))); - testCase.verifyTrue(any(contains(notices, ... - "warnings and errors"))); + testCase.verifyTrue(any(contains(notices, "first ERROR"))); testCase.verifyTrue(any(contains(notices, "in-memory"))); testCase.verifyTrue(any(contains(notices, "coalesced"))); testCase.verifyTrue(any(contains(notices, "expired"))); @@ -125,7 +125,7 @@ function streamsProjectionHealthToConsumersInSequenceOrder(testCase) "Synthetic analysis completed.", ... Category="app.probe.log-projection.analysis", ... Audience="user"); - projection.setFilters(Level="trace", Audience="all"); + projection.setFilters(Level="trace"); events = projection.view().events; testCase.verifyGreaterThan( ... @@ -162,8 +162,8 @@ function streamsProjectionHealthToConsumersInSequenceOrder(testCase) applicationState, callbackContext) category = "app.probe.log-projection.analysis"; callbackContext.log( ... - "trace", "analysis.trace_step", ... - "Synthetic trace step.", Category=category, Audience="developer"); + "trace", "analysis.trace_before_error", ... + "Synthetic trace before error.", Category=category, Audience="developer"); callbackContext.log( ... "debug", "analysis.branch_selected", ... "Synthetic branch selected.", Category=category, Audience="developer"); @@ -174,6 +174,14 @@ function streamsProjectionHealthToConsumersInSequenceOrder(testCase) "warning", "analysis.fallback_used", ... "Synthetic fallback remained usable.", ... Category=category, Audience="developer"); +callbackContext.log( ... + "error", "analysis.failed", ... + "Synthetic analysis failed.", Category=category, Audience="user", ... + Exception=MException("labkit:test:SyntheticIncident", ... + "Synthetic incident.")); +callbackContext.log( ... + "trace", "analysis.trace_after_error", ... + "Synthetic trace after error.", Category=category, Audience="developer"); end function failConsumer(~) diff --git a/tests/specs/labkit/app/SessionLogViewerSpec.m b/tests/specs/labkit/app/SessionLogViewerSpec.m index 5caf2ed42..9e7b92b7e 100644 --- a/tests/specs/labkit/app/SessionLogViewerSpec.m +++ b/tests/specs/labkit/app/SessionLogViewerSpec.m @@ -2,18 +2,20 @@ % SESSIONLOGVIEWERSPEC Regression: ordinary hidden GUI sessions expose one interactive standard log viewer without mutating canonical history. methods (Test, TestTags = {'Contract:source', 'Env:hidden-gui'}) - function toolsMenuOpensOneLiveViewerAndControlsTrace(testCase) + function toolsMenuOpensNamedViewerWithManualTraceControl(testCase) runtime = viewerRuntime(testCase); cleanup = onCleanup(@() runtime.close()); appFigure = runtime.figureHandle(); openMenu = oneHandle( ... appFigure, "labkitAppUtilitySessionLog"); - traceMenu = oneHandle( ... - appFigure, "labkitAppUtilityTraceCapture"); + testCase.verifyEmpty(findall( ... + appFigure, "Tag", "labkitAppUtilityTraceCapture")); invoke(openMenu.MenuSelectedFcn, openMenu, []); viewerFigure = oneHandle( ... groot, "labkitSessionLogViewer"); + testCase.verifyEqual(string(viewerFigure.Name), ... + "Log viewer probe — Session Log"); testCase.verifyEqual(string(viewerFigure.Visible), "off"); tableHandle = oneHandle( ... viewerFigure, "labkitSessionLogTable"); @@ -31,13 +33,17 @@ function toolsMenuOpensOneLiveViewerAndControlsTrace(testCase) invoke(openMenu.MenuSelectedFcn, openMenu, []); testCase.verifyNumElements(findall( ... groot, "Tag", "labkitSessionLogViewer"), 1); + viewerFigure = oneHandle( ... + groot, "labkitSessionLogViewer"); - invoke(traceMenu.MenuSelectedFcn, traceMenu, []); - testCase.verifyEqual(string(traceMenu.Checked), "on"); + traceButton = oneHandle( ... + viewerFigure, "labkitSessionLogTraceCapture"); + invoke(traceButton.ButtonPushedFcn, traceButton, []); + testCase.verifyEqual(string(traceButton.Text), "Disable TRACE"); snapshot = runtime.diagnosticSnapshot(); testCase.verifyTrue(snapshot.traceEnabled); - invoke(traceMenu.MenuSelectedFcn, traceMenu, []); - testCase.verifyEqual(string(traceMenu.Checked), "off"); + invoke(traceButton.ButtonPushedFcn, traceButton, []); + testCase.verifyEqual(string(traceButton.Text), "Enable TRACE"); snapshot = runtime.diagnosticSnapshot(); testCase.verifyFalse(snapshot.traceEnabled); clear cleanup @@ -58,10 +64,6 @@ function inspectsEarlierDebugFiltersLongMessagesAndClearsOnlyView(testCase) viewerFigure, "labkitSessionLogTable"); level = oneHandle( ... viewerFigure, "labkitSessionLogLevel"); - audience = oneHandle( ... - viewerFigure, "labkitSessionLogAudience"); - audienceLabel = oneHandle( ... - viewerFigure, "labkitSessionLogAudienceLabel"); rootLabel = oneHandle( ... viewerFigure, "labkitSessionLogRootLabel"); rootAction = oneHandle( ... @@ -70,11 +72,14 @@ function inspectsEarlierDebugFiltersLongMessagesAndClearsOnlyView(testCase) testCase.verifyEqual( ... string(tableHandle.Data.Properties.VariableNames), ... ["Time", "Level", "Area", "Message"]); - testCase.verifyEqual(string(audienceLabel.Text), "View"); + testCase.verifyEmpty(findall( ... + viewerFigure, "Tag", "labkitSessionLogAudience")); + testCase.verifyEmpty(findall( ... + viewerFigure, "Tag", "labkitSessionLogFollow")); testCase.verifyEqual(string(rootLabel.Text), "Action"); - testCase.verifyEqual(string(audience.Items), ... - ["Useful (default)", "Everything", ... - "User workflow", "Developer details"]); + testCase.verifyEqual(string(level.Items), ... + ["Full TRACE", "DEBUG", "User"]); + testCase.verifyEqual(string(level.Value), "trace"); rootIds = string(rootAction.ItemsData(2:end)); rootLabels = string(rootAction.Items(2:end)); testCase.verifyTrue(all(startsWith(rootIds, "op-"))); @@ -85,8 +90,8 @@ function inspectsEarlierDebugFiltersLongMessagesAndClearsOnlyView(testCase) string(tableHandle.Data.Level) == "ERROR")); level.Value = "debug"; invoke(level.ValueChangedFcn, level, []); - audience.Value = "all"; - invoke(audience.ValueChangedFcn, audience, []); + testCase.verifyFalse(any( ... + string(tableHandle.Data.Level) == "TRACE")); testCase.verifyTrue(any( ... string(tableHandle.Data.Message) == ... "Synthetic branch selected.")); @@ -140,21 +145,22 @@ function retainsOneNativeTableAcrossALargeIncrementalBurst(testCase) end function exportsTheLiveBundleFromToolsAndTheViewer(testCase) - folder = testCase.applyFixture( ... - matlab.unittest.fixtures.TemporaryFolderFixture).Folder; - destination = fullfile(folder, "live-diagnostics.zip"); - backend = struct( ... - "chooseOutputFile", @(~, ~) ... - labkit.app.dialog.Choice(destination)); + backend = struct("alert", @(~, ~) []); runtime = viewerRuntime(testCase, backend); cleanup = onCleanup(@() runtime.close()); runtime.invokeAction("run"); + folder = diagnosticArtifactsFolder(); + before = diagnosticFiles(folder); appFigure = runtime.figureHandle(); exportMenu = oneHandle( ... appFigure, "labkitAppUtilityExportDiagnostics"); invoke(exportMenu.MenuSelectedFcn, exportMenu, []); - testCase.verifyTrue(isfile(destination)); + afterMenu = diagnosticFiles(folder); + menuFile = setdiff(afterMenu, before); + testCase.verifyNumElements(menuFile, 1); + fileCleanup = onCleanup(@() deleteDiagnostics( ... + folder, setdiff(diagnosticFiles(folder), before))); openMenu = oneHandle( ... appFigure, "labkitAppUtilitySessionLog"); @@ -164,12 +170,13 @@ function exportsTheLiveBundleFromToolsAndTheViewer(testCase) exportButton = oneHandle( ... viewerFigure, "labkitSessionLogExport"); invoke(exportButton.ButtonPushedFcn, exportButton, []); - testCase.verifyTrue(isfile(destination)); + afterViewer = diagnosticFiles(folder); + testCase.verifyNumElements(setdiff(afterViewer, before), 2); records = runtime.diagnosticEvents(); testCase.verifyGreaterThanOrEqual(sum( ... string({records.eventName}) == ... "diagnostics.bundle_exported.completed"), 2); - clear cleanup + clear fileCleanup cleanup end end end @@ -236,3 +243,27 @@ function exportsTheLiveBundleFromToolsAndTheViewer(testCase) function invoke(callback, varargin) callback(varargin{:}); end + +function folder = diagnosticArtifactsFolder() +folder = string(fileparts(which( ... + "labkit.app.internal.RuntimeKernel"))); +for index = 1:3 + folder = string(fileparts(folder)); +end +folder = fullfile(folder, "artifacts", "diagnostics"); +end + +function files = diagnosticFiles(folder) +entries = dir(fullfile(folder, ... + "labkit-diagnostics-probe-log-viewer-*.zip")); +files = string({entries.name}); +end + +function deleteDiagnostics(folder, files) +for file = files + filepath = fullfile(folder, file); + if isfile(filepath) + delete(filepath); + end +end +end From 9ee6896bf71cccbe0aa9fd373a57c2d0f9c0fe4b Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 13:27:50 -0500 Subject: [PATCH 03/32] fix: clarify validation and launcher labels --- +labkit/+app/+internal/+launcher/dispatch.m | 16 ++-- .agents/skills/labkit-test-planner/SKILL.md | 9 +++ AGENTS.md | 9 ++- docs/apps/README.md | 5 +- docs/apps/labkit-core/launcher/README.md | 13 ++-- .../maintain-and-release/documentation.md | 2 +- .../maintain-and-release/testing.md | 29 ++++++- ...-focused-validation-and-launcher-labels.md | 76 +++++++++++++++++++ labkit_launcher.m | 2 +- .../internal/launcher/LauncherDispatchSpec.m | 14 ++-- 10 files changed, 146 insertions(+), 29 deletions(-) create mode 100644 docs/history/records/2026/08/LK-20260803-focused-validation-and-launcher-labels.md diff --git a/+labkit/+app/+internal/+launcher/dispatch.m b/+labkit/+app/+internal/+launcher/dispatch.m index 341d9b6b0..75f53b59c 100644 --- a/+labkit/+app/+internal/+launcher/dispatch.m +++ b/+labkit/+app/+internal/+launcher/dispatch.m @@ -144,7 +144,7 @@ maintenanceGrid.RowSpacing = 5; maintenanceGrid.ColumnSpacing = 6; docsToolButton = uibutton(maintenanceGrid, ... - "Text", "Generate Local Documentation"); + "Text", "Doc Generation"); codeButton = uibutton(maintenanceGrid, "Text", "Run Code Analyzer"); profileButton = uibutton(maintenanceGrid, "Text", "Profile Selected App"); cleanButton = uibutton(maintenanceGrid, "Text", "Clean Artifacts"); @@ -176,7 +176,7 @@ tableGrid.Padding = [4 4 4 4]; appTable = uitable(tableGrid, ... "ColumnName", { ... - "Package", "App", "Family", "Version", "Access", "Updated"}, ... + "Package", "Family", "App", "Version", "Access", "Updated"}, ... "ColumnEditable", [true false false false false false], ... "RowName", {}, ... "FontSize", tableFontSize); @@ -516,8 +516,8 @@ function updateInfo() info = struct( ... "name", "labkit_launcher", ... "displayName", "LabKit App Launcher", ... - "version", "1.8.2", ... - "updated", "2026-07-30"); + "version", "1.8.3", ... + "updated", "2026-08-03"); end function position = defaultLauncherPosition() @@ -537,8 +537,8 @@ function updateInfo() function widths = launcherTableWidths(figureWidth, controlWidth) tableWidth = max(640, double(figureWidth) - double(controlWidth) - 36); -minimum = [62 180 120 70 72 90]; -preferred = [72 240 150 78 80 100]; +minimum = [62 120 180 70 72 90]; +preferred = [72 150 240 78 80 100]; if tableWidth <= sum(minimum) values = minimum; elseif tableWidth < sum(preferred) @@ -547,7 +547,7 @@ function updateInfo() values = minimum + fraction .* (preferred - minimum); else extra = tableWidth - sum(preferred); - values = preferred + extra .* [0 0.60 0.25 0 0 0.15]; + values = preferred + extra .* [0 0.25 0.60 0 0 0.15]; end widths = num2cell(round(values)); end @@ -598,8 +598,8 @@ function configureTable(tableHandle, selectionCallback, doubleClickCallback) for index = 1:numel(apps) rows(index, :) = { ... checked(index), ... - char(apps(index).name), ... char(apps(index).family), ... + char(apps(index).name), ... char(apps(index).version), ... char(apps(index).visibility), ... char(apps(index).updated)}; diff --git a/.agents/skills/labkit-test-planner/SKILL.md b/.agents/skills/labkit-test-planner/SKILL.md index 9f563f33a..2fb1bc6e5 100644 --- a/.agents/skills/labkit-test-planner/SKILL.md +++ b/.agents/skills/labkit-test-planner/SKILL.md @@ -94,3 +94,12 @@ Report the exact owner/contract or profile command, selected identity count, pass/fail result, artifact folder, GUI/manual boundary, and why any broader gate is intentionally deferred. For final integration, report `changedFast` and the CI state for the exact pushed commit. + +## Repair CI Failures + +Read only the failed check and copy its exact test identity. Reproduce the +smallest method, owning specification file, or owner/contract; repair that +source boundary; rerun the same focused evidence; then push and let CI restore +the full platform claim. Do not rerun `changedFast` or a full local profile for +each CI repair. Re-plan only when the repair intentionally changes additional +behavior or ownership. diff --git a/AGENTS.md b/AGENTS.md index 6e4bc93e8..df8d69cef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,8 +154,13 @@ tests, history, and details out of the public repository. changed task; required PR CI owns complete validation. The protected main-push run repeats only policy and the aggregate gate for the exact squash commit because its tree is the already-validated PR result. -- After failure, fix and rerun the narrowest failed file, method, or suite; do not repeatedly - invoke the planner. Exact commands and scope live in +- After a local or hosted-CI failure, inspect only the failing identity and its + log, fix the smallest responsible source boundary, and rerun the narrowest + failed method, specification file, or owner/contract. Push the focused repair + and let required CI re-establish the complete claim; do not rerun + `changedFast` or a local full profile after every CI repair. Re-plan only when + the repair intentionally widens the changed behavior or ownership boundary. + Exact commands and scope live in `docs/development/maintain-and-release/testing.md`. - MATLAB and GitHub inspection require host runtime/network permissions. Run every `gh` command with host permissions on its first attempt, including diff --git a/docs/apps/README.md b/docs/apps/README.md index 77f32ad9c..4167bf172 100644 --- a/docs/apps/README.md +++ b/docs/apps/README.md @@ -95,8 +95,9 @@ choices, workflow-specific defaults, result schemas, and exports. See the [App Framework](../framework/README.md) for behavior shared across apps. Every App opens as a clean project. Use **Tools > Diagnostics** to inspect the -current session history, enable future trace capture, or export a diagnostic -bundle after a problem. Apps with declared sample generation expose **Tools > +current session history or export a diagnostic bundle after a problem. Manual +TRACE capture is controlled inside the Session Log window. Apps with declared +sample generation expose **Tools > Developer Tools > Generate Synthetic Inputs...**; generation writes anonymous inputs without loading them or changing the open project. The [runtime guide](../framework/guides/runtime.md) defines these shared contracts. diff --git a/docs/apps/labkit-core/launcher/README.md b/docs/apps/labkit-core/launcher/README.md index 860c8750c..57bf1eb1d 100644 --- a/docs/apps/labkit-core/launcher/README.md +++ b/docs/apps/labkit-core/launcher/README.md @@ -26,7 +26,7 @@ tool availability, or the active maintenance operation. | Versions and Install | **Latest** | Installs the current `main` branch archive. | | Versions and Install | **Release** | Installs the latest stable GitHub release. | | Versions and Install | **Versions** | Opens the release, tag, and commit selector for deliberate upgrade or rollback. | -| Development and Maintenance | **Generate Local Documentation** | Rebuilds the complete ignored `site/` folder from the current Markdown and public MATLAB help. It does not open a page or choose between online and local help. | +| Development and Maintenance | **Doc Generation** | Rebuilds the complete ignored `site/` folder from the current Markdown and public MATLAB help. It does not open a page or choose between online and local help. | | Development and Maintenance | **Run Code Analyzer** | Scans the checkout and writes JSON and HTML Code Analyzer reports. | | Development and Maintenance | **Profile Selected App** | Starts the selected app under the MATLAB profiler and saves its report when the app closes. | | Development and Maintenance | **Clean Artifacts** | Removes ignored generated reports under `artifacts/`; it does not delete app projects or exported laboratory results. | @@ -35,7 +35,9 @@ tool availability, or the active maintenance operation. Double-clicking an app row is equivalent to selecting it and opening it normally. The checkbox column controls package membership; ordinary launch -selection does not change the checked set. +selection does not change the checked set. The application table places +**Family** immediately before **App** so related tools remain visually grouped +while their individual names stay easy to scan. When startup begins, the launcher immediately disables its App table and actions, changes the open button to **Starting App...**, shows a wait pointer, @@ -47,8 +49,9 @@ opened command; failure reports the failing identifier and message, with repair guidance only for structural installation failures. Every launch uses the same clean App path. Use the App's **Tools > -Diagnostics** menu to inspect its live session log, enable trace capture, or -export a diagnostic ZIP after a problem occurs. Apps that declare a synthetic +Diagnostics** menu to inspect its live session log or export a diagnostic +bundle after a problem occurs. The Session Log window owns manual TRACE +capture when earlier detail is needed. Apps that declare a synthetic input pack expose **Tools > Developer Tools > Generate Synthetic Inputs...**. Generation writes anonymous fixture files and a manifest into a new folder but does not load them or mutate the running project. @@ -82,7 +85,7 @@ Documentation lookup uses the discovered public App folder and the unique path-conventional manual at `docs/apps///README.md`. It does not require a separately maintained App catalog. The visible launcher opens online documentation by default. Local generation is an explicit source-checkout -convenience: **Generate Local Documentation** always rebuilds the ignored +convenience: **Doc Generation** always rebuilds the ignored `site/` folder and reports completion without opening a browser. The deployed site is generated independently from `main` by GitHub Actions. diff --git a/docs/development/maintain-and-release/documentation.md b/docs/development/maintain-and-release/documentation.md index 748b168ca..1c38047ed 100644 --- a/docs/development/maintain-and-release/documentation.md +++ b/docs/development/maintain-and-release/documentation.md @@ -299,7 +299,7 @@ folder is ignored by Git and may be deleted or regenerated at any time. The Documentation Pages workflow performs the same build from the exact `main` source and deploys the resulting artifact to GitHub Pages. It never commits generated files back to `main` or `develop`. The visible Launcher opens -that online site by default. Its **Generate Local Documentation** maintenance +that online site by default. Its **Doc Generation** maintenance action always rebuilds the complete ignored `site/` folder from current sources; it does not open a page or choose a reading destination. diff --git a/docs/development/maintain-and-release/testing.md b/docs/development/maintain-and-release/testing.md index 34c896f3c..ec3d4287e 100644 --- a/docs/development/maintain-and-release/testing.md +++ b/docs/development/maintain-and-release/testing.md @@ -112,7 +112,7 @@ buildtool docsCheck | Task | Purpose | | --- | --- | -| `changedFast` | Local final pre-commit/pre-push gate. Reads tracked and untracked working-tree paths; on a clean checkpoint it reads `HEAD^..HEAD`. | +| `changedFast` | Final local pre-PR review gate, run once after the complete `develop` diff is ready. Reads tracked and untracked working-tree paths; on a clean checkpoint it reads `HEAD^..HEAD`. | | `headless` | Every headless catalog identity. | | `gui` | Every hidden-GUI catalog identity. | | `isolated` | Every path-isolated catalog identity. | @@ -153,9 +153,30 @@ coverage-html/ coverage runs only ``` After a failure, copy its exact class/method identity from the output and run -the smallest owner/contract or exact source that proves the repair. A zero -selection or missing-contract error is a test-authoring defect, never passing -evidence. +the smallest method, specification file, owner/contract, or exact source that +proves the repair. Do not invoke the planner again when the failing identity is +already known. A zero selection or missing-contract error is a test-authoring +defect, never passing evidence. + +### CI Repair Loop + +Once the pull request exists, required CI owns the broad platform claim. For a +failed check: + +1. inspect only the failed check and the log for its exact failing identity; +2. reproduce that method, specification file, or smallest owner/contract + locally when reproduction is useful; +3. repair the smallest responsible source boundary and rerun only that focused + evidence; +4. push the repair to the existing PR branch and let CI rerun its required + profiles. + +Do not run `changedFast`, `headless`, `gui`, `isolated`, or the complete local +matrix after every CI repair. The one pre-PR `changedFast` run remains the local +integration checkpoint; CI re-establishes the complete claim after later +focused fixes. Re-run a wider local closure only when the repair itself expands +the intended behavior, component ownership, or compatibility boundary beyond +the original failure. When a mapped layout change leaves a non-automatable boundary, its plan can name a manual check. It is printed and recorded in `plan.json`, but it never diff --git a/docs/history/records/2026/08/LK-20260803-focused-validation-and-launcher-labels.md b/docs/history/records/2026/08/LK-20260803-focused-validation-and-launcher-labels.md new file mode 100644 index 000000000..0e27e4d93 --- /dev/null +++ b/docs/history/records/2026/08/LK-20260803-focused-validation-and-launcher-labels.md @@ -0,0 +1,76 @@ +# Focused validation policy and Launcher labels reduce iteration friction + +```labkit-change +id: LK-20260803-focused-validation-and-launcher-labels +date: 2026-08-03 +sequence: 169 +type: fix +compatibility: compatible +component: `labkit_launcher` | `1.8.2 -> 1.8.3` +component: `repository` +scope: Focused local and CI repair validation +scope: Launcher application table readability +scope: Launcher documentation action wording +``` + +## Context + +The validation policy already required focused tests during development and a +single `changedFast` run before PR review, but one build-task description still +called `changedFast` a pre-commit and pre-push gate. The CI repair loop also did +not state explicitly that a known failure should be reproduced and rerun at its +narrowest identity. In the Launcher, Family appeared after the App name and the +local documentation action used a label too long for its compact button. + +## Decision and rationale + +Describe validation as an explicit staged workflow: focused evidence during +iteration, one local integration gate before the PR, then failure-directed +repairs while required CI owns the broad claim. Keep the Launcher catalog API +unchanged while reordering only its visible columns. Use the short +**Doc Generation** label and retain the existing tooltip and behavior. + +## Changes + +- Clarified that ordinary commits and checkpoint pushes do not require + `changedFast`, and that it runs once when the complete `develop` diff is ready + for PR review. +- Added a CI repair loop that reads only failed logs, reruns the smallest known + identity, pushes the focused repair, and leaves broad revalidation to CI. +- Reordered the visible application table to Package, Family, App, Version, + Access, and Updated without changing `labkit_launcher("list")` output. +- Shortened **Generate Local Documentation** to **Doc Generation**. + +## User and data impact + +Developers avoid repeated broad local validation during fast iteration and CI +repair. Launcher users can scan families before individual App names, and the +maintenance controls fit their available width more clearly. App discovery, +launching, package selection, documentation generation, and programmatic +catalog data are unchanged. + +## Compatibility and migration + +The change is compatible. No App, project, result, installation, or catalog +schema migration is required. Automation that calls Launcher programmatic +modes is unaffected; only visible GUI ordering and button text change. + +## Validation + +Focused Launcher dispatch specifications cover the visible column order, +family and App cell mapping, responsive column widths, the shortened button, +and the unchanged documentation generation callback. Documentation validation +covers authored links, history structure, and deterministic rendering. + +## Evidence + +- Launcher dispatch focused closure: 13 unaffected identities passed; the one + corrected GUI identity passed on its exact-method rerun. +- Authored Markdown link validation. +- Deterministic `docsCheck` generation. + +## Known limitations and follow-up + +Automated hidden Launcher construction does not prove native text rendering at +every operating-system scale factor. The existing responsive width contract +and minimum window size remain the automated boundary. diff --git a/labkit_launcher.m b/labkit_launcher.m index f123c191b..c57b069ee 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -11,7 +11,7 @@ % APPCOMMAND, "local") returns its generated local HTML page and raises % labkit:app:internal:launcher:LocalDocumentationMissing when `site/` has % not been generated. The programmatic form never opens a browser or prompt. -% In the visible Launcher, Generate Local Documentation always rebuilds the +% In the visible Launcher, Doc Generation always rebuilds the % complete ignored `site/` folder and does not open a documentation page. if nargout > 1 diff --git a/tests/specs/labkit/app/internal/launcher/LauncherDispatchSpec.m b/tests/specs/labkit/app/internal/launcher/LauncherDispatchSpec.m index f22ca195b..02302f445 100644 --- a/tests/specs/labkit/app/internal/launcher/LauncherDispatchSpec.m +++ b/tests/specs/labkit/app/internal/launcher/LauncherDispatchSpec.m @@ -135,7 +135,7 @@ function localDocumentationButtonOnlyRegeneratesCurrentSite(testCase) fig = labkit.app.internal.launcher.dispatch(root); button = findall(fig, "Type", "uibutton", ... - "Text", "Generate Local Documentation"); + "Text", "Doc Generation"); button.ButtonPushedFcn(button, []); call = getappdata(groot, "fixtureToolCall"); @@ -188,7 +188,9 @@ function launcherPreservesVisualSelectionAndDoubleClickContracts(testCase) "Versions and Install", "Development and Maintenance", ... "Package and Publish"], panels))); testCase.verifyEqual(string(appTable.ColumnName), [ ... - "Package"; "App"; "Family"; "Version"; "Access"; "Updated"]); + "Package"; "Family"; "App"; "Version"; "Access"; "Updated"]); + testCase.verifyEqual(string(appTable.Data{1, 2}), "Fixture"); + testCase.verifyEqual(string(appTable.Data{1, 3}), "Alpha"); testCase.verifyEqual(appTable.ColumnEditable, ... [true false false false false false]); testCase.verifyEqual(appTable.FontSize, 12); @@ -196,7 +198,7 @@ function launcherPreservesVisualSelectionAndDoubleClickContracts(testCase) @(value) isnumeric(value) && isscalar(value), ... appTable.ColumnWidth))); initialWidths = cell2mat(appTable.ColumnWidth); - testCase.verifyGreaterThan(initialWidths(2), initialWidths(1)); + testCase.verifyGreaterThan(initialWidths(3), initialWidths(1)); fig.Position(3) = max(800, fig.Position(3) - 160); fig.SizeChangedFcn(fig, []); drawnow; @@ -204,11 +206,11 @@ function launcherPreservesVisualSelectionAndDoubleClickContracts(testCase) testCase.verifyLessThanOrEqual( ... sum(resizedWidths), sum(initialWidths)); testCase.verifyGreaterThanOrEqual(resizedWidths, ... - [62 180 120 70 72 90]); + [62 120 180 70 72 90]); testCase.verifyTrue(all(ismember([ ... "Open Selected App", "Refresh App List", ... "Documentation and History", "Latest", "Release", "Versions", ... - "Generate Local Documentation", "Run Code Analyzer", ... + "Doc Generation", "Run Code Analyzer", ... "Profile Selected App", "Clean Artifacts", ... "Package Checked", "Checked P-code"], buttons))); testCase.verifyFalse(any(buttons == "Open Debug")); @@ -368,7 +370,7 @@ function toolButtonsAdaptExactPublicContracts(testCase) addpath(dottedMaintenance, "-begin"); fig = labkit.app.internal.launcher.dispatch(root); buttons = [ ... - "Clean Artifacts", "Generate Local Documentation", ... + "Clean Artifacts", "Doc Generation", ... "Run Code Analyzer", "Profile Selected App"]; expected = [ ... "cleanLabKitArtifacts", "renderLabKitDocs", ... From 203838ff46131735d8823636ea85fbefeba38ba0 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 13:56:21 -0500 Subject: [PATCH 04/32] fix: restore electrochem batch imports --- +labkit/+app/+internal/LayoutNode.m | 13 ++- +labkit/+app/+internal/LayoutNodeValues.m | 15 ++++ .../+app/+internal/RuntimeContractBoundary.m | 70 +++++++++++++++ +labkit/+app/+internal/RuntimeKernel.m | 20 +++++ +labkit/+app/+layout/fileList.m | 8 ++ +labkit/+app/version.m | 2 +- apps/AGENTS.md | 5 ++ .../+sourceFiles/matchesDtaKind.m | 13 +++ .../+chrono_overlay/+workbench/buildLayout.m | 2 + .../+chrono_overlay/definition.m | 4 +- .../cic/+cic/+sourceFiles/matchesDtaKind.m | 12 +++ .../cic/+cic/+workbench/buildLayout.m | 4 +- apps/electrochem/cic/+cic/definition.m | 2 +- .../csc/+csc/+sourceFiles/matchesDtaKind.m | 12 +++ .../csc/+csc/+workbench/buildLayout.m | 4 +- apps/electrochem/csc/+csc/definition.m | 2 +- .../eis/+eis/+sourceFiles/matchesDtaKind.m | 12 +++ .../eis/+eis/+workbench/buildLayout.m | 2 + apps/electrochem/eis/+eis/definition.m | 2 +- .../+sourceFiles/matchesDtaKind.m | 13 +++ .../+vt_resistance/+workbench/buildLayout.m | 4 +- .../vt_resistance/+vt_resistance/definition.m | 2 +- docs/apps/electrochemistry/README.md | 8 +- .../electrochemistry/chrono-overlay/README.md | 8 +- docs/framework/README.md | 5 ++ ...0803-electrochem-batch-source-filtering.md | 87 +++++++++++++++++++ .../sourceFiles/ChronoOverlaySourceSpec.m | 12 +++ .../workbench/ChronoOverlayWorkflowSpec.m | 5 +- .../cic/sourceFiles/CicSourceSpec.m | 11 +++ .../cic/workbench/CicWorkflowSpec.m | 7 +- .../csc/sourceFiles/CscSourceSpec.m | 11 +++ .../csc/workbench/CscWorkflowSpec.m | 9 +- .../eis/sourceFiles/EisSourceSpec.m | 11 +++ .../eis/workbench/EisWorkflowSpec.m | 5 +- .../sourceFiles/VtResistanceSourceSpec.m | 12 +++ .../workbench/VtResistanceWorkflowSpec.m | 7 +- tests/specs/labkit/app/AppSdkSpec.m | 65 ++++++++++++++ 37 files changed, 457 insertions(+), 29 deletions(-) create mode 100644 apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/matchesDtaKind.m create mode 100644 apps/electrochem/cic/+cic/+sourceFiles/matchesDtaKind.m create mode 100644 apps/electrochem/csc/+csc/+sourceFiles/matchesDtaKind.m create mode 100644 apps/electrochem/eis/+eis/+sourceFiles/matchesDtaKind.m create mode 100644 apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/matchesDtaKind.m create mode 100644 docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md diff --git a/+labkit/+app/+internal/LayoutNode.m b/+labkit/+app/+internal/LayoutNode.m index 7aa4d7dd7..24ddf34de 100644 --- a/+labkit/+app/+internal/LayoutNode.m +++ b/+labkit/+app/+internal/LayoutNode.m @@ -66,6 +66,8 @@ % Mode - "files" or "folder" for fileList. Default: "files". % SelectionMode - "single" or "multiple" for fileList. Default: % "multiple". + % PathFilter - Optional fileList callback accepted = callback(paths). + % Default: empty. % % Outputs: % node - Immutable semantic labkit.app.internal.LayoutNode value. @@ -245,7 +247,8 @@ "ChooseLabel", "FolderLabel", "RecursiveFolderLabel", ... "RemoveLabel", "ClearLabel", "EmptyText", "Bind", ... "SelectionBind", "SourceRole", "SourceIdPrefix", "Required", ... - "AllowDuplicatePaths", "OnSelectionChanged", ... + "AllowDuplicatePaths", "PathFilter", ... + "PathFilterDescription", "OnSelectionChanged", ... "ChooseTooltip", "FolderTooltip", ... "RecursiveFolderTooltip", "RemoveTooltip", "ClearTooltip"]; options = labkit.app.internal.OptionParser.parse( ... @@ -308,6 +311,14 @@ "AllowDuplicatePaths", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... options, "AllowDuplicatePaths", false), ... "AllowDuplicatePaths"), ... + "PathFilter", labkit.app.internal.LayoutNodeValues.pathFilterCallback( ... + labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "PathFilter", [])), ... + "PathFilterDescription", ... + labkit.app.internal.LayoutNodeValues.nonemptyText( ... + labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "PathFilterDescription", "supported"), ... + "PathFilterDescription"), ... "Bind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue(options, "Bind", "")), ... "SelectionBind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue( ... options, "SelectionBind", "")), ... diff --git a/+labkit/+app/+internal/LayoutNodeValues.m b/+labkit/+app/+internal/LayoutNodeValues.m index 7c411b995..4902c18e0 100644 --- a/+labkit/+app/+internal/LayoutNodeValues.m +++ b/+labkit/+app/+internal/LayoutNodeValues.m @@ -95,6 +95,21 @@ function validateChildKinds(children, allowed, parent) end end + function callback = pathFilterCallback(callback) + if isempty(callback) + return; + end + if ~isa(callback, "function_handle") || ~isscalar(callback) + error("labkit:app:contract:InvalidValue", ... + "layout.fileList PathFilter must be a function handle."); + end + if nargin(callback) ~= 1 || nargout(callback) ~= 1 + error("labkit:app:contract:CallbackRoleMismatch", ... + "layout.fileList PathFilter must accept paths and return " + ... + "one logical mask."); + end + end + function specs = interactionSpecs(specs, plotId, axisIds) if isempty(specs) specs = {}; diff --git a/+labkit/+app/+internal/RuntimeContractBoundary.m b/+labkit/+app/+internal/RuntimeContractBoundary.m index b222159b3..7c36e6026 100644 --- a/+labkit/+app/+internal/RuntimeContractBoundary.m +++ b/+labkit/+app/+internal/RuntimeContractBoundary.m @@ -67,6 +67,42 @@ state, config.Bind); end + function [paths, result] = filterFilePaths( ... + config, paths, currentPaths) + result = struct("changed", false, "acceptedCount", 0, ... + "rejectedCount", 0, "message", ""); + if isempty(config.PathFilter) || isempty(paths) + return; + end + paths = normalizeFilePaths(paths); + currentPaths = normalizeFilePaths(currentPaths); + proposed = ~ismember(paths, currentPaths); + candidatePaths = paths(proposed); + if isempty(candidatePaths) + return; + end + accepted = config.PathFilter(candidatePaths); + if ~(islogical(accepted) && isrow(accepted) && ... + numel(accepted) == numel(candidatePaths)) + error("labkit:app:contract:InvalidValue", ... + "fileList PathFilter must return one logical value " + ... + "per newly proposed path."); + end + retained = ~proposed; + retained(proposed) = accepted; + paths = paths(retained); + rejectedCount = sum(~accepted); + result.changed = rejectedCount > 0; + if rejectedCount > 0 + acceptedCount = sum(accepted); + result.acceptedCount = acceptedCount; + result.rejectedCount = rejectedCount; + result.message = filterNotice( ... + acceptedCount, rejectedCount, ... + config.PathFilterDescription); + end + end + function adapter = createAdapter(application, contract, platform) if ~(ischar(platform) || ... (isstring(platform) && isscalar(platform))) @@ -158,3 +194,37 @@ function validateState(application, state) end end end + +function paths = normalizeFilePaths(paths) +if ischar(paths) || (isstring(paths) && isscalar(paths)) + paths = string(paths); +elseif isstring(paths) + paths = reshape(paths, 1, []); +elseif iscell(paths) + valid = cellfun(@(value) ischar(value) || ... + (isstring(value) && isscalar(value)), paths); + if ~all(valid, "all") + invalidFilePaths(); + end + paths = reshape(string(paths), 1, []); +else + invalidFilePaths(); +end +end + +function invalidFilePaths() +error("labkit:app:contract:InvalidValue", ... + "fileList paths must be text paths."); +end + +function message = filterNotice(acceptedCount, rejectedCount, description) +if acceptedCount == 0 + message = sprintf( ... + "No %s files matched. Filtered %d unsupported file(s).", ... + description, rejectedCount); +else + message = sprintf( ... + "Kept %d %s file(s) and filtered %d unsupported file(s).", ... + acceptedCount, description, rejectedCount); +end +end diff --git a/+labkit/+app/+internal/RuntimeKernel.m b/+labkit/+app/+internal/RuntimeKernel.m index a93f3f28d..471aa472c 100644 --- a/+labkit/+app/+internal/RuntimeKernel.m +++ b/+labkit/+app/+internal/RuntimeKernel.m @@ -630,12 +630,32 @@ function applyFileSelection(obj, target, paths, indices) [config, current] = ... labkit.app.internal.RuntimeContractBoundary.fileListState( ... obj.Contract, obj.State, target); + currentRole = obj.Sources.recordsForRole( ... + current, config.SourceRole); + currentPaths = obj.Sources.sourcePaths(currentRole); + [paths, filtering] = ... + labkit.app.internal.RuntimeContractBoundary.filterFilePaths( ... + config, paths, currentPaths); + if filtering.changed + obj.Recorder.log( ... + "info", "source.paths_filtered", ... + "Filtered unsupported source files.", ... + Category="runtime.source", Audience="user", ... + Attributes=struct( ... + "acceptedCount", filtering.acceptedCount, ... + "rejectedCount", filtering.rejectedCount)); + obj.Context.alert(filtering.message, ... + "Unsupported files filtered"); + end sources = obj.Sources.reconcileRolePaths( ... current, paths, config.SourceRole, ... config.SourceIdPrefix, config.Required, ... config.AllowDuplicatePaths); visibleSources = obj.Sources.recordsForRole( ... sources, config.SourceRole); + if filtering.changed + indices = 1:numel(visibleSources); + end if nargin < 4 indices = 1:numel(visibleSources); end diff --git a/+labkit/+app/+layout/fileList.m b/+labkit/+app/+layout/fileList.m index f850b5081..082989fd8 100644 --- a/+labkit/+app/+layout/fileList.m +++ b/+labkit/+app/+layout/fileList.m @@ -35,6 +35,14 @@ % AllowDuplicatePaths - Preserve separate portable source records that % resolve to the same path. Use this when each list row is a distinct % workflow task. Default: false. +% PathFilter - Optional callback accepted = callback(paths). paths is a row +% string array containing newly proposed files. accepted must be a +% logical row with one value per path. Rejected paths are omitted before +% portable source records are created, and the runtime reports aggregate +% retained/filtered counts without exposing filenames. Default: empty. +% PathFilterDescription - Reader-facing description of files accepted by +% PathFilter, used in the aggregate filtering notice. Default: +% "supported". % Bind - Project source-record field path. Default: "". % SelectionBind - ListSelection field path. Default: "". % OnSelectionChanged - Optional callback diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index 3e0d1b954..7f4b1f59f 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -30,6 +30,6 @@ % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "2.2.0", ">=2 <3", "stable", ... + "app", "2.3.0", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 547bdd986..7b4957fc1 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -123,6 +123,11 @@ find the exact owner and contract; App authors never invent test paths. ## Version, docs, and tests +- Document framework-provided default lifecycle and interaction behavior only + in the owning framework manual and public API help. Family manuals own + family-domain meaning; App manuals own only App-specific meaning or explicit + deviations. Never restate an SDK default across family or App pages, and + never copy one shared-behavior paragraph across every App page. - Source or user-visible behavior changes update `AppVersion` and `Updated` in the App's `definition.m`, owned documentation, and component history before the `develop` PR is merge-ready. diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/matchesDtaKind.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..399a90677 --- /dev/null +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,13 @@ +% Expected caller: Chrono Overlay fileList PathFilter. Input is newly +% proposed source paths. Output retains only DTA files detected as chrono; +% no GUI side effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "chrono"; +end +end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m index d20f9e724..e24f33c0e 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m @@ -11,6 +11,8 @@ RecursiveFolderLabel="Add folder tree", ... RemoveLabel="Remove selected", ClearLabel="Clear all", ... EmptyText="No files loaded", ... + PathFilter=@chrono_overlay.sourceFiles.matchesDtaKind, ... + PathFilterDescription="chrono DTA", ... Bind="project.inputs.sources", ... SelectionBind="session.selection.files", ... SourceRole="chrono", SourceIdPrefix="dta", Required=true); diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m index ae96a6e85..f7a888365 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m @@ -7,8 +7,8 @@ Title="Gamry Multi-DTA Plot Export GUI", ... DisplayName="Chrono Overlay", ... Family="Electrochem", ... - AppVersion="1.6.1", ... - Updated="2026-07-30", ... + AppVersion="1.6.2", ... + Updated="2026-08-03", ... Requirements=labkit.contract.requirements( ... "app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=chrono_overlay.projectSpec(), ... diff --git a/apps/electrochem/cic/+cic/+sourceFiles/matchesDtaKind.m b/apps/electrochem/cic/+cic/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..41a33f8fe --- /dev/null +++ b/apps/electrochem/cic/+cic/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,12 @@ +% Expected caller: CIC fileList PathFilter. Input is newly proposed source +% paths. Output retains only DTA files detected as chrono; no GUI side effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "chrono"; +end +end diff --git a/apps/electrochem/cic/+cic/+workbench/buildLayout.m b/apps/electrochem/cic/+cic/+workbench/buildLayout.m index 8524cbc9d..0c9500cf3 100644 --- a/apps/electrochem/cic/+cic/+workbench/buildLayout.m +++ b/apps/electrochem/cic/+cic/+workbench/buildLayout.m @@ -11,7 +11,9 @@ RemoveLabel="Remove selected", ClearLabel="Clear all", ... EmptyText="No files loaded", ... Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)"], ... - SelectionMode="single", ... + SelectionMode="multiple", ... + PathFilter=@cic.sourceFiles.matchesDtaKind, ... + PathFilterDescription="chrono DTA", ... Bind="project.inputs.sources", ... SelectionBind="session.selection.files", ... SourceRole="chrono", SourceIdPrefix="dta"); diff --git a/apps/electrochem/cic/+cic/definition.m b/apps/electrochem/cic/+cic/definition.m index ae2948e81..3f9bf9cdd 100644 --- a/apps/electrochem/cic/+cic/definition.m +++ b/apps/electrochem/cic/+cic/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CIC_app", AppId="cic", ... Title="Gamry CIC GUI (Voltage Transient)", DisplayName="CIC", ... - Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Electrochem", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=cic.projectSpec(), CreateSession=@cic.createSession, ... Workbench=cic.workbench.buildLayout(), ... diff --git a/apps/electrochem/csc/+csc/+sourceFiles/matchesDtaKind.m b/apps/electrochem/csc/+csc/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..42963b809 --- /dev/null +++ b/apps/electrochem/csc/+csc/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,12 @@ +% Expected caller: CSC fileList PathFilter. Input is newly proposed source +% paths. Output retains only DTA files detected as CV/CT; no GUI side effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "cvct"; +end +end diff --git a/apps/electrochem/csc/+csc/+workbench/buildLayout.m b/apps/electrochem/csc/+csc/+workbench/buildLayout.m index 5d6242039..87455f27c 100644 --- a/apps/electrochem/csc/+csc/+workbench/buildLayout.m +++ b/apps/electrochem/csc/+csc/+workbench/buildLayout.m @@ -9,7 +9,9 @@ RemoveLabel="Remove selected", ClearLabel="Clear all", ... EmptyText="No files loaded", ... Filters=["*.DTA;*.dta", "Gamry DTA files (*.DTA)"], ... - SelectionMode="single", ... + SelectionMode="multiple", ... + PathFilter=@csc.sourceFiles.matchesDtaKind, ... + PathFilterDescription="CV/CT DTA", ... Bind="project.inputs.sources", ... SelectionBind="session.selection.files", ... SourceRole="cvct", SourceIdPrefix="dta", ... diff --git a/apps/electrochem/csc/+csc/definition.m b/apps/electrochem/csc/+csc/definition.m index a6fca855f..beff08b90 100644 --- a/apps/electrochem/csc/+csc/definition.m +++ b/apps/electrochem/csc/+csc/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CSC_app", AppId="csc", ... Title="Gamry DTA GUI (literature CSC)", DisplayName="CSC", ... - Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Electrochem", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=csc.projectSpec(), CreateSession=@csc.createSession, ... Workbench=csc.workbench.buildLayout(), ... diff --git a/apps/electrochem/eis/+eis/+sourceFiles/matchesDtaKind.m b/apps/electrochem/eis/+eis/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..1434a3432 --- /dev/null +++ b/apps/electrochem/eis/+eis/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,12 @@ +% Expected caller: EIS fileList PathFilter. Input is newly proposed source +% paths. Output retains only DTA files detected as EIS; no GUI side effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "eis"; +end +end diff --git a/apps/electrochem/eis/+eis/+workbench/buildLayout.m b/apps/electrochem/eis/+eis/+workbench/buildLayout.m index 74a532e3c..2878571ab 100644 --- a/apps/electrochem/eis/+eis/+workbench/buildLayout.m +++ b/apps/electrochem/eis/+eis/+workbench/buildLayout.m @@ -10,6 +10,8 @@ RecursiveFolderLabel="Add folder tree", ... RemoveLabel="Remove selected", ClearLabel="Clear all", ... EmptyText="No files loaded", ... + PathFilter=@eis.sourceFiles.matchesDtaKind, ... + PathFilterDescription="EIS DTA", ... Bind="project.inputs.sources", SelectionBind="session.selection.files", ... SourceRole="eis", SourceIdPrefix="dta"); filesSection = labkit.app.layout.section("filesSection", "Files", { ... diff --git a/apps/electrochem/eis/+eis/definition.m b/apps/electrochem/eis/+eis/definition.m index 6f8f8e0e7..e13eb058d 100644 --- a/apps/electrochem/eis/+eis/definition.m +++ b/apps/electrochem/eis/+eis/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_EIS_app", AppId="eis", ... Title="Gamry EIS Multi-DTA Plot GUI", DisplayName="EIS Overlay", ... - Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Electrochem", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=eis.projectSpec(), CreateSession=@eis.createSession, ... Workbench=eis.workbench.buildLayout(), ... diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/matchesDtaKind.m b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..82d521243 --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,13 @@ +% Expected caller: VT Resistance fileList PathFilter. Input is newly proposed +% source paths. Output retains only DTA files detected as chrono; no GUI side +% effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "chrono"; +end +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m index 8df66b486..aa7c9d941 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m @@ -3,7 +3,9 @@ %BUILDLAYOUT Compose VT Resistance's file, analysis, plot, and export flow. choices = vt_resistance.analysisRun.analysisChoices(); files = labkit.app.layout.fileList("files", Label="Files", ... - Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)"], SelectionMode="single", ... + Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)"], SelectionMode="multiple", ... + PathFilter=@vt_resistance.sourceFiles.matchesDtaKind, ... + PathFilterDescription="chrono DTA", ... ChooseLabel="Add DTA files", FolderLabel="Add folder", ... ChooseTooltip="Add Gamry chrono DTA files containing pulse voltage and current traces for resistance analysis.", ... RecursiveFolderLabel="Add folder tree", ... diff --git a/apps/electrochem/vt_resistance/+vt_resistance/definition.m b/apps/electrochem/vt_resistance/+vt_resistance/definition.m index 14a80f463..6f85a33d0 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/definition.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_VTResistance_app", AppId="vt_resistance", ... Title="VT Steady Resistance", DisplayName="VT Resistance", ... - Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Electrochem", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=vt_resistance.projectSpec(), CreateSession=@vt_resistance.createSession, ... Workbench=vt_resistance.workbench.buildLayout(), ... diff --git a/docs/apps/electrochemistry/README.md b/docs/apps/electrochemistry/README.md index a088d752b..e41c97136 100644 --- a/docs/apps/electrochemistry/README.md +++ b/docs/apps/electrochemistry/README.md @@ -14,13 +14,7 @@ the owning app. Source DTA files are never modified. | Impedance inspection and export | [EIS](eis/README.md) | `ZCURVE` | configurable Nyquist/Bode-style overlay | | Steady pulse resistance | [VT Resistance](vt-resistance/README.md) | biphasic chrono transient | cathodic, anodic, and mean resistance | -## Shared File Behavior - -File controls accept one or more `.DTA` files from one folder in a single -selection. A canceled chooser leaves the current project unchanged. CIC, CSC, -and VT Resistance use the selected row as the active preview while retaining -the loaded source list for batch export. Invalid items are reported per file; -one failed item does not silently replace another result. +## Shared DTA Contract The DTA library returns structured items, curve tables, headers, units, metadata, parser messages, and status. Apps use exact required columns for diff --git a/docs/apps/electrochemistry/chrono-overlay/README.md b/docs/apps/electrochemistry/chrono-overlay/README.md index 0de6ea902..97b6a87e0 100644 --- a/docs/apps/electrochemistry/chrono-overlay/README.md +++ b/docs/apps/electrochemistry/chrono-overlay/README.md @@ -14,11 +14,9 @@ labkit_ChronoOverlay_app ## Inputs -Use **Add DTA files** to select one or more `.DTA` files from one directory. -The app parses each file as chrono data and reports unreadable items. The file -list controls curve order, legend labels, and removal; selection does not -discard other loaded curves. Saved projects preserve the ordered file list and -reopen it through portable source references. +Inputs are one or more `.DTA` files containing chrono data. Source order +controls curve order and legend labels. Saved projects preserve that order and +reopen sources through portable references. ## Basic Workflow diff --git a/docs/framework/README.md b/docs/framework/README.md index ae45d4b7e..67bcc4b09 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -80,6 +80,11 @@ and renderer signatures, and builds one private native platform plan. Set `AllowDuplicatePaths=true` only when separate workflow tasks may share one resolved path, and present row-level workflow state with `Snapshot.fileItemStatuses`. + For content formats that cannot be distinguished by filename extension, + declare a pure batch `PathFilter` and a reader-facing + `PathFilterDescription`. The runtime applies the predicate only to newly + proposed files, omits rejected paths before source records are created, and + reports aggregate kept/filtered counts without exposing filenames. Source changes rebuild the transient session; Apps do not mirror choose, remove, clear, or selection UI events. - Give every scientific or workflow action an App-owned `Tooltip`. The diff --git a/docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md b/docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md new file mode 100644 index 000000000..61b9ded31 --- /dev/null +++ b/docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md @@ -0,0 +1,87 @@ +# Electrochemistry batch imports retain compatible DTA files + +```labkit-change +id: LK-20260803-electrochem-batch-source-filtering +date: 2026-08-03 +sequence: 170 +type: fix +compatibility: compatible +component: `labkit.app` | `2.2.0 -> 2.3.0` +component: `labkit_ChronoOverlay_app` | `1.6.1 -> 1.6.2` +component: `labkit_CIC_app` | `1.6.1 -> 1.6.2` +component: `labkit_CSC_app` | `1.6.1 -> 1.6.2` +component: `labkit_EIS_app` | `1.6.1 -> 1.6.2` +component: `labkit_VTResistance_app` | `1.6.1 -> 1.6.2` +scope: Electrochemistry multi-file import +scope: Folder and recursive DTA filtering +scope: App SDK file-list path predicates +``` + +## Context + +CIC, CSC, and VT Resistance configured their file dialogs for single +selection even though their workflows and exports support batches. Folder and +recursive-folder actions selected every `.DTA` path by extension, so a folder +containing another Gamry experiment type caused session reconstruction to +fail transactionally and discarded compatible files in the same batch. + +## Decision and rationale + +Extend the existing App SDK file-list contract with a domain-neutral batch +path predicate and a standard aggregate filtering notice. Keep experiment +type detection in each electrochemistry App through the DTA facade: the SDK +owns selection lifecycle and interaction consistency, while Apps retain the +scientific meaning of chrono, CV/CT, and EIS inputs. + +## Changes + +- Added `PathFilter` and `PathFilterDescription` to + `labkit.app.layout.fileList`. +- Applied predicates only to newly proposed paths, retained previously + accepted sources, validated the returned logical mask, and omitted rejected + paths before portable source records were created. +- Added one aggregate, filename-free notice when unsupported files are + filtered. +- Enabled native multi-file selection for CIC, CSC, and VT Resistance. +- Declared chrono, CV/CT, or EIS predicates for all five electrochemistry Apps. + +## User and data impact + +Users can select several files at once and import a folder or folder tree even +when it contains other DTA experiment types. Matching files keep their order +and portable identities; unsupported paths are not stored. The notice reports +counts only and does not expose source filenames or paths. Source files and +saved project schemas are unchanged. + +## Compatibility and migration + +The change is compatible. Existing projects reopen without migration, existing +accepted sources remain registered, and Apps requiring `labkit.app >=2 <3` +remain within that range. Scientific formulas, units, analysis parameters, +result schemas, and export values are unchanged. + +## Validation + +Focused App SDK source specifications cover callback signature validation, +batch mask application, preservation of existing sources, portable-source +alignment, and aggregate notice wording. App-owned source specifications cover +chrono, CV/CT, and EIS discrimination. One existing hidden-GUI workflow per +electrochemistry App covers the mixed batch through plotting, analysis, +export, and project restore; CIC, CSC, and VT Resistance also verify native +multiple selection. + +## Evidence + +- App SDK plus five App source specification files: 29 identities passed. +- Five electrochemistry hidden-GUI workflow specification files: 5 identities + passed. +- Authored-link validation checked 252 Markdown sources with no unresolved + links; deterministic documentation generation compared 382 files across + two independent renders. + +## Known limitations and follow-up + +Automated tests do not operate native file and folder dialogs or prove +behavior on approved laboratory data. The predicates use the supported DTA +content detector; a malformed file that cannot be classified is intentionally +reported as filtered rather than registered as an analysis source. diff --git a/tests/specs/apps/electrochem/chrono_overlay/sourceFiles/ChronoOverlaySourceSpec.m b/tests/specs/apps/electrochem/chrono_overlay/sourceFiles/ChronoOverlaySourceSpec.m index 3fb37c697..c87f35f96 100644 --- a/tests/specs/apps/electrochem/chrono_overlay/sourceFiles/ChronoOverlaySourceSpec.m +++ b/tests/specs/apps/electrochem/chrono_overlay/sourceFiles/ChronoOverlaySourceSpec.m @@ -25,6 +25,18 @@ function fallsBackToTheFirstSampleWhenNoPulseExists(testCase) testCase.verifyEqual(aligned.tAligned_s, [0; 1; 2], "AbsTol", 1e-12); testCase.verifySubstring(string(message), "fallback to first sample"); end + + function acceptsOnlyChronoDtaPaths(testCase) + chrono = testfixtures.dtaFixturePath( ... + "chrono_chronopot_current_pulse_0p2ms.DTA"); + eisPath = testfixtures.dtaFixturePath( ... + "eis_potentiostatic_zcurve.DTA"); + + accepted = chrono_overlay.sourceFiles.matchesDtaKind( ... + [chrono, eisPath]); + + testCase.verifyEqual(accepted, [true false]); + end end methods (Static, Access = private) diff --git a/tests/specs/apps/electrochem/chrono_overlay/workbench/ChronoOverlayWorkflowSpec.m b/tests/specs/apps/electrochem/chrono_overlay/workbench/ChronoOverlayWorkflowSpec.m index a839fd52c..7d990ac59 100644 --- a/tests/specs/apps/electrochem/chrono_overlay/workbench/ChronoOverlayWorkflowSpec.m +++ b/tests/specs/apps/electrochem/chrono_overlay/workbench/ChronoOverlayWorkflowSpec.m @@ -5,6 +5,8 @@ function loadsAlignsExportsAndRestoresAChronoTrace(testCase) source = testfixtures.dtaFixturePath( ... "chrono_chronopot_current_pulse_0p2ms.DTA"); + unsupported = testfixtures.dtaFixturePath( ... + "eis_potentiostatic_zcurve.DTA"); folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; output = fullfile(folder, "overlay.csv"); @@ -18,10 +20,11 @@ function loadsAlignsExportsAndRestoresAChronoTrace(testCase) cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); - runtime.applyFileSelection("files", source, 1); + runtime.applyFileSelection("files", [source, unsupported], 1:2); voltage = findall(figureValue, "Tag", "overlayPlots.voltage"); current = findall(figureValue, "Tag", "overlayPlots.current"); testCase.verifyNumElements(runtime.State.session.cache.items, 1); + testCase.verifyNumElements(runtime.State.project.inputs.sources, 1); testCase.verifyNotEmpty(voltage.Children); testCase.verifyNotEmpty(current.Children); runtime.invokeAction("exportCurves"); diff --git a/tests/specs/apps/electrochem/cic/sourceFiles/CicSourceSpec.m b/tests/specs/apps/electrochem/cic/sourceFiles/CicSourceSpec.m index 49445c807..b96ee5d2e 100644 --- a/tests/specs/apps/electrochem/cic/sourceFiles/CicSourceSpec.m +++ b/tests/specs/apps/electrochem/cic/sourceFiles/CicSourceSpec.m @@ -11,5 +11,16 @@ function emptySourcesReturnTheDeclaredStructVector(testCase) testCase.verifyClass(items, "struct"); testCase.verifySize(items, [0 0]); end + + function acceptsOnlyChronoDtaPaths(testCase) + chrono = testfixtures.dtaFixturePath( ... + "chrono_chronopot_current_pulse_0p2ms.DTA"); + eisPath = testfixtures.dtaFixturePath( ... + "eis_potentiostatic_zcurve.DTA"); + + accepted = cic.sourceFiles.matchesDtaKind([chrono, eisPath]); + + testCase.verifyEqual(accepted, [true false]); + end end end diff --git a/tests/specs/apps/electrochem/cic/workbench/CicWorkflowSpec.m b/tests/specs/apps/electrochem/cic/workbench/CicWorkflowSpec.m index 2193a9a58..b332a3513 100644 --- a/tests/specs/apps/electrochem/cic/workbench/CicWorkflowSpec.m +++ b/tests/specs/apps/electrochem/cic/workbench/CicWorkflowSpec.m @@ -7,6 +7,8 @@ function loadsRecomputesExportsAndRestoresAChronoSession(testCase) 'chrono_chronopot_current_pulse_0p2ms.DTA'); second = testfixtures.dtaFixturePath( ... 'chrono_chronopot_current_pulse_1ms.DTA'); + unsupported = testfixtures.dtaFixturePath( ... + 'eis_potentiostatic_zcurve.DTA'); folder = string(tempname); mkdir(folder); cleanupFolder = onCleanup(@() removeFolder(folder)); @@ -22,6 +24,8 @@ function loadsRecomputesExportsAndRestoresAChronoSession(testCase) figure = runtime.figureHandle(); verifySemanticLayout(testCase, figure); + testCase.verifyEqual(string( ... + findall(figure, "Tag", "files").Multiselect), "on"); runtime.applyFileSelection("files", string(first), 1); results = findall(figure, "Tag", "results"); testCase.verifyEqual(size(results.Data), [1, 8]); @@ -30,7 +34,8 @@ function loadsRecomputesExportsAndRestoresAChronoSession(testCase) testCase.verifyTrue(contains(string( ... findall(figure, "Tag", "detect").Value), "metadata-current")); - runtime.applyFileSelection("files", [string(first), string(second)], 2); + runtime.applyFileSelection("files", ... + [string(first), string(second), string(unsupported)], 1:3); sourceIds = string({runtime.State.project.inputs.sources.id}); before = results.Data; runtime.applyControlValue("areaOverride", "2"); diff --git a/tests/specs/apps/electrochem/csc/sourceFiles/CscSourceSpec.m b/tests/specs/apps/electrochem/csc/sourceFiles/CscSourceSpec.m index f763d41e5..a2a51cade 100644 --- a/tests/specs/apps/electrochem/csc/sourceFiles/CscSourceSpec.m +++ b/tests/specs/apps/electrochem/csc/sourceFiles/CscSourceSpec.m @@ -8,5 +8,16 @@ function emptySourcesReturnTheDeclaredStructVector(testCase) testCase.verifyClass(items, "struct"); testCase.verifySize(items, [0 0]); end + + function acceptsOnlyCvCtDtaPaths(testCase) + cvct = testfixtures.dtaFixturePath( ... + "cv_cyclic_voltammetry_pt_reference.DTA"); + eisPath = testfixtures.dtaFixturePath( ... + "eis_potentiostatic_zcurve.DTA"); + + accepted = csc.sourceFiles.matchesDtaKind([cvct, eisPath]); + + testCase.verifyEqual(accepted, [true false]); + end end end diff --git a/tests/specs/apps/electrochem/csc/workbench/CscWorkflowSpec.m b/tests/specs/apps/electrochem/csc/workbench/CscWorkflowSpec.m index 11a022598..0355d94ce 100644 --- a/tests/specs/apps/electrochem/csc/workbench/CscWorkflowSpec.m +++ b/tests/specs/apps/electrochem/csc/workbench/CscWorkflowSpec.m @@ -5,16 +5,20 @@ function loadsACvCtFileAndUpdatesComparisonPlots(testCase) source = testfixtures.dtaFixturePath( ... "cv_cyclic_voltammetry_pt_reference.DTA"); + unsupported = testfixtures.dtaFixturePath( ... + "eis_potentiostatic_zcurve.DTA"); folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; definition = csc.definition(); journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - definition, [], struct(), journal); + definition, [], struct("alert", @(~, ~) []), journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); - runtime.applyFileSelection("files", source, 1); + testCase.verifyEqual(string( ... + findall(figureValue, "Tag", "files").Multiselect), "on"); + runtime.applyFileSelection("files", [source, unsupported], 1:2); tableValue = findall(figureValue, "Tag", "cycleResults"); top = findall(figureValue, "Tag", "plotAxes.top"); bottom = findall(figureValue, "Tag", "plotAxes.bottom"); @@ -22,6 +26,7 @@ function loadsACvCtFileAndUpdatesComparisonPlots(testCase) csc.analysisRun.analysisChoices().modes(2)); testCase.verifyNumElements(runtime.State.session.cache.items, 1); + testCase.verifyNumElements(runtime.State.project.inputs.sources, 1); testCase.verifyGreaterThan(size(tableValue.Data, 1), 0); testCase.verifyNotEmpty(top.Children); testCase.verifyNotEmpty(bottom.Children); diff --git a/tests/specs/apps/electrochem/eis/sourceFiles/EisSourceSpec.m b/tests/specs/apps/electrochem/eis/sourceFiles/EisSourceSpec.m index f28278647..2d4eb2cc3 100644 --- a/tests/specs/apps/electrochem/eis/sourceFiles/EisSourceSpec.m +++ b/tests/specs/apps/electrochem/eis/sourceFiles/EisSourceSpec.m @@ -17,5 +17,16 @@ function summarizesCanonicalZcurveItems(testCase) testCase.verifySubstring(string(summary{2}), "Freq"); testCase.verifySubstring(string(summary{2}), "low->high/mixed"); end + + function acceptsOnlyEisDtaPaths(testCase) + eisPath = testfixtures.dtaFixturePath( ... + "eis_potentiostatic_zcurve.DTA"); + chrono = testfixtures.dtaFixturePath( ... + "chrono_chronopot_current_pulse_0p2ms.DTA"); + + accepted = eis.sourceFiles.matchesDtaKind([eisPath, chrono]); + + testCase.verifyEqual(accepted, [true false]); + end end end diff --git a/tests/specs/apps/electrochem/eis/workbench/EisWorkflowSpec.m b/tests/specs/apps/electrochem/eis/workbench/EisWorkflowSpec.m index 7a9623d14..72c9dc9be 100644 --- a/tests/specs/apps/electrochem/eis/workbench/EisWorkflowSpec.m +++ b/tests/specs/apps/electrochem/eis/workbench/EisWorkflowSpec.m @@ -4,6 +4,8 @@ methods (Test, TestTags = {'Contract:presentation', 'Env:hidden-gui'}) function loadsPlotsExportsAndRestoresAnEisFile(testCase) source = testfixtures.dtaFixturePath("eis_potentiostatic_zcurve.DTA"); + unsupported = testfixtures.dtaFixturePath( ... + "chrono_chronopot_current_pulse_0p2ms.DTA"); folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; output = fullfile(folder, "eis.csv"); @@ -17,7 +19,7 @@ function loadsPlotsExportsAndRestoresAnEisFile(testCase) cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); - runtime.applyFileSelection("files", source, 1); + runtime.applyFileSelection("files", [source, unsupported], 1:2); axesValue = findall(figureValue, "Tag", "plot.main"); units = eis.impedanceDisplay.catalog(); runtime.applyControlValue("impedanceUnit", units.choices(4)); @@ -25,6 +27,7 @@ function loadsPlotsExportsAndRestoresAnEisFile(testCase) runtime.invokeAction("exportPlot"); testCase.verifyNumElements(runtime.State.session.cache.items, 1); + testCase.verifyNumElements(runtime.State.project.inputs.sources, 1); testCase.verifyNotEmpty(axesValue.Children); testCase.verifySubstring(string(axesValue.XLabel.String), ... units.choices(4)); diff --git a/tests/specs/apps/electrochem/vt_resistance/sourceFiles/VtResistanceSourceSpec.m b/tests/specs/apps/electrochem/vt_resistance/sourceFiles/VtResistanceSourceSpec.m index 77d57c3a9..718f2b975 100644 --- a/tests/specs/apps/electrochem/vt_resistance/sourceFiles/VtResistanceSourceSpec.m +++ b/tests/specs/apps/electrochem/vt_resistance/sourceFiles/VtResistanceSourceSpec.m @@ -11,5 +11,17 @@ function emptySourcesReturnTheDeclaredStructVector(testCase) testCase.verifyClass(items, "struct"); testCase.verifySize(items, [0 0]); end + + function acceptsOnlyChronoDtaPaths(testCase) + chrono = testfixtures.dtaFixturePath( ... + "chrono_chronopot_current_pulse_0p2ms.DTA"); + eisPath = testfixtures.dtaFixturePath( ... + "eis_potentiostatic_zcurve.DTA"); + + accepted = vt_resistance.sourceFiles.matchesDtaKind( ... + [chrono, eisPath]); + + testCase.verifyEqual(accepted, [true false]); + end end end diff --git a/tests/specs/apps/electrochem/vt_resistance/workbench/VtResistanceWorkflowSpec.m b/tests/specs/apps/electrochem/vt_resistance/workbench/VtResistanceWorkflowSpec.m index 123effa45..8fcf07f21 100644 --- a/tests/specs/apps/electrochem/vt_resistance/workbench/VtResistanceWorkflowSpec.m +++ b/tests/specs/apps/electrochem/vt_resistance/workbench/VtResistanceWorkflowSpec.m @@ -5,6 +5,8 @@ function loadsRecomputesExportsAndRestoresAChronoFile(testCase) source = testfixtures.dtaFixturePath( ... "chrono_chronopot_current_pulse_0p2ms.DTA"); + unsupported = testfixtures.dtaFixturePath( ... + "eis_potentiostatic_zcurve.DTA"); folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; output = fullfile(folder, "resistance.csv"); @@ -18,13 +20,16 @@ function loadsRecomputesExportsAndRestoresAChronoFile(testCase) cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); - runtime.applyFileSelection("files", source, 1); + testCase.verifyEqual(string( ... + findall(figureValue, "Tag", "files").Multiselect), "on"); + runtime.applyFileSelection("files", [source, unsupported], 1:2); results = findall(figureValue, "Tag", "results"); runtime.applyControlValue("steadyWindow", ... vt_resistance.analysisRun.analysisChoices().steadyWindows(2)); runtime.invokeAction("exportResults"); testCase.verifyNumElements(runtime.State.session.cache.items, 1); + testCase.verifyNumElements(runtime.State.project.inputs.sources, 1); testCase.verifyEqual(size(results.Data), [1 9]); testCase.verifyNotEmpty(findall(figureValue, "Tag", "plotAxes.top").Children); testCase.verifyNotEmpty(findall(figureValue, "Tag", "plotAxes.bottom").Children); diff --git a/tests/specs/labkit/app/AppSdkSpec.m b/tests/specs/labkit/app/AppSdkSpec.m index 758d13505..868a6249e 100644 --- a/tests/specs/labkit/app/AppSdkSpec.m +++ b/tests/specs/labkit/app/AppSdkSpec.m @@ -208,6 +208,58 @@ function sourceSelectionNormalizesSupportedPathShapes(testCase) clear cleanup end + function sourcePathFilterKeepsMatchesAndReportsAggregateCounts(testCase) + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.fileList("files", ... + Bind="project.inputs.sources", ... + PathFilter=@acceptPngPaths, ... + PathFilterDescription="PNG image")}); + app = AppSdkSpec.definition(layout, "ProjectSchema", ... + labkit.app.project.Schema( ... + Version=1, Create=@createSourceProject, ... + Validate=@validateSourceProject)); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + notices = containers.Map("KeyType", "char", "ValueType", "any"); + backend = struct("alert", @(message, title) ... + captureAlert(notices, message, title)); + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + app, [], backend, journal); + cleanup = onCleanup(@() runtime.close()); + + runtime.applyFileSelection("files", ... + ["first.png", "notes.txt", "second.PNG"], 1:3); + + sources = runtime.State.project.inputs.sources; + testCase.verifyEqual(numel(sources), 2); + testCase.verifyEqual(string(arrayfun(@(source) ... + source.reference.originalPath, sources)), ... + ["first.png"; "second.PNG"]); + testCase.verifyEqual(notices("title"), ... + "Unsupported files filtered"); + testCase.verifyEqual(notices("message"), ... + "Kept 2 PNG image file(s) and filtered 1 unsupported file(s)."); + records = runtime.diagnosticEvents(); + filtered = records(find(string({records.eventName}) == ... + "source.paths_filtered", 1, "last")); + testCase.verifyEqual(filtered.attributes.acceptedCount, 2); + testCase.verifyEqual(filtered.attributes.rejectedCount, 1); + + runtime.applyFileSelection("files", ... + ["first.png", "second.PNG", "readme.md"], 1:3); + testCase.verifyEqual(numel(runtime.State.project.inputs.sources), 2); + testCase.verifyEqual(notices("message"), ... + "No PNG image files matched. Filtered 1 unsupported file(s)."); + clear cleanup + end + + function rejectsMalformedFilePathFilters(testCase) + testCase.verifyError(@() labkit.app.layout.fileList("files", ... + PathFilter=@wrongPathFilter), ... + "labkit:app:contract:CallbackRoleMismatch"); + end + function syntheticInputsAreDeliberateAndDoNotChangeTheRuntime(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; @@ -417,6 +469,19 @@ function exposesSyntheticInputGenerationAsAnOrdinaryTool(testCase) paths = ("resolved-" + ids(:)); end +function accepted = acceptPngPaths(paths) +accepted = endsWith(lower(paths), ".png"); +end + +function accepted = wrongPathFilter(~, ~) +accepted = true; +end + +function captureAlert(store, message, title) +store("message") = string(message); +store("title") = string(title); +end + function accepted = validateProject(project) accepted = isstruct(project) && isscalar(project) && ... isfield(project, "parameters") && isstruct(project.parameters) && ... From dde1d6641882e167d7fed8679ecc1878e014ffda Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 13:56:26 -0500 Subject: [PATCH 05/32] test: configure focused specs across apps --- .../scripts/runFocusedSpecs.m | 43 ++++++++++++++++++- AGENTS.md | 11 +++++ ...-focused-validation-and-launcher-labels.md | 15 +++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m b/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m index f82081272..32d74668e 100644 --- a/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m +++ b/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m @@ -31,13 +31,54 @@ matlab.unittest.TestSuite.fromFile(char(filepath)); end suite = [selected{:}]; + sourcePathCleanup = configureSourcePaths(repoRoot, specsRoot, selectedPaths); environmentCleanup = configureEnvironment(selectedPaths); fprintf("LabKit focused specifications: %d identities from %d file(s).\n", ... numel(suite), numel(specFiles)); results = run(suite); disp(table(results)); assertSuccess(results); - clear environmentCleanup + clear environmentCleanup sourcePathCleanup +end + +function cleanup = configureSourcePaths(repoRoot, specsRoot, paths) +% App specifications need their independently launchable App roots on path. +% Add every represented App rather than assuming all selected specs share the +% first App owner. +appSpecsRoot = string(fullfile(specsRoot, "apps")) + filesep; +sourceRoots = strings(0, 1); +for filepath = paths.' + if ~startsWith(filepath, appSpecsRoot) + continue; + end + relative = extractAfter(filepath, strlength(appSpecsRoot)); + parts = split(relative, filesep); + if numel(parts) < 3 || parts(1) == "conformance" + continue; + end + sourceRoot = string(fullfile(repoRoot, "apps", parts(1), parts(2))); + if isfolder(sourceRoot) + sourceRoots(end + 1, 1) = sourceRoot; + end +end +sourceRoots = unique(sourceRoots, "stable"); +existing = string(strsplit(path, pathsep)); +added = strings(0, 1); +for sourceRoot = sourceRoots.' + if ~any(existing == sourceRoot) + addpath(char(sourceRoot), "-begin"); + added(end + 1, 1) = sourceRoot; + end +end +cleanup = onCleanup(@() removeSourcePaths(added)); +end + +function removeSourcePaths(paths) +for sourceRoot = paths.' + if any(string(strsplit(path, pathsep)) == sourceRoot) + rmpath(char(sourceRoot)); + end +end end function cleanup = configureEnvironment(paths) diff --git a/AGENTS.md b/AGENTS.md index df8d69cef..720bed02e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,6 +262,17 @@ explicit compliant squash subject; do not rely on GitHub defaults. - History records use stable Change ID and sequence metadata plus rationale, compatibility, user/data impact, validation, evidence, and follow-up. Do not restore a root changelog or separate history parser. +- While work remains on `develop`, treat component history as the net pending + integration record rather than a commit diary. Merge compatible incremental + changes into an existing unpublished record when they share the same + component evolution, user outcome, and compatibility decision. Before the + squash PR is ready for review, compare the complete base-to-head change and + rewrite its history as the smallest coherent set of independently reviewable + product decisions: fold minor follow-on edits into their owning record, + remove records that describe no durable transition, and consolidate + development-only version steps so each affected component advances exactly + once for the net change. Update rationale, compatibility, user impact, and + evidence to describe the final PR diff rather than its commit sequence. - New release tags are `vX.Y.Z`; do not rename published legacy tags. Release titles are `LabKit MATLAB Workbench vX.Y.Z` with relevant `Highlights`, `Fixes`, `Upgrade Note`, and `Validation` sections. diff --git a/docs/history/records/2026/08/LK-20260803-focused-validation-and-launcher-labels.md b/docs/history/records/2026/08/LK-20260803-focused-validation-and-launcher-labels.md index 0e27e4d93..88a65a64c 100644 --- a/docs/history/records/2026/08/LK-20260803-focused-validation-and-launcher-labels.md +++ b/docs/history/records/2026/08/LK-20260803-focused-validation-and-launcher-labels.md @@ -21,6 +21,10 @@ called `changedFast` a pre-commit and pre-push gate. The CI repair loop also did not state explicitly that a known failure should be reproduced and rerun at its narrowest identity. In the Launcher, Family appeared after the App name and the local documentation action used a label too long for its compact button. +The agent-only focused-spec runner also configured repository and test paths +but not every independently launchable App root represented by a multi-file +selection, so one invocation spanning several Apps could fail to resolve +production packages after the first specification. ## Decision and rationale @@ -29,6 +33,9 @@ iteration, one local integration gate before the PR, then failure-directed repairs while required CI owns the broad claim. Keep the Launcher catalog API unchanged while reordering only its visible columns. Use the short **Doc Generation** label and retain the existing tooltip and behavior. +Let the focused-spec runner derive every represented App root from the selected +specification paths, add the unique roots for the run, and remove only paths it +added when execution ends. ## Changes @@ -40,6 +47,9 @@ unchanged while reordering only its visible columns. Use the short - Reordered the visible application table to Package, Family, App, Version, Access, and Updated without changing `labkit_launcher("list")` output. - Shortened **Generate Local Documentation** to **Doc Generation**. +- Corrected multi-App focused-spec execution so one explicit file list can + resolve production packages from every represented App without widening the + selected test identities. ## User and data impact @@ -48,6 +58,8 @@ repair. Launcher users can scan families before individual App names, and the maintenance controls fit their available width more clearly. App discovery, launching, package selection, documentation generation, and programmatic catalog data are unchanged. +Focused multi-App iterations no longer require manual path assembly or +separate MATLAB startups per App. ## Compatibility and migration @@ -68,6 +80,9 @@ covers authored links, history structure, and deterministic rendering. corrected GUI identity passed on its exact-method rerun. - Authored Markdown link validation. - Deterministic `docsCheck` generation. +- One six-file focused invocation loaded App SDK evidence and source + specifications from five electrochemistry Apps; all 29 selected identities + passed after the path correction. ## Known limitations and follow-up From becd0a62bc8c3f50dc6545a1a08372836428e0bd Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 13:57:25 -0500 Subject: [PATCH 06/32] chore: consolidate pending sdk version --- +labkit/+app/version.m | 2 +- .../2026/08/LK-20260803-electrochem-batch-source-filtering.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index 7f4b1f59f..3e0d1b954 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -30,6 +30,6 @@ % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "2.3.0", ">=2 <3", "stable", ... + "app", "2.2.0", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md b/docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md index 61b9ded31..26e92c283 100644 --- a/docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md +++ b/docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md @@ -6,7 +6,7 @@ date: 2026-08-03 sequence: 170 type: fix compatibility: compatible -component: `labkit.app` | `2.2.0 -> 2.3.0` +component: `labkit.app` component: `labkit_ChronoOverlay_app` | `1.6.1 -> 1.6.2` component: `labkit_CIC_app` | `1.6.1 -> 1.6.2` component: `labkit_CSC_app` | `1.6.1 -> 1.6.2` From deb129aa270647da80680dae63e70c43890b4ba1 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 14:29:20 -0500 Subject: [PATCH 07/32] fix: improve dic registration feedback --- .../+internal/private/reconcileInteractions.m | 5 +- .../+analysisRun/autoAlignMovingToReference.m | 80 ++++++++++++++----- .../+analysisRun/runAutomaticRegistration.m | 6 +- docs/apps/dic/dic-preprocess/README.md | 26 +++--- ...process-interaction-registration-repair.md | 31 ++++--- .../analysisRun/DicPreprocessScientificSpec.m | 14 +++- .../workbench/DicPreprocessWorkflowSpec.m | 29 ++++++- 7 files changed, 144 insertions(+), 47 deletions(-) diff --git a/+labkit/+app/+internal/private/reconcileInteractions.m b/+labkit/+app/+internal/private/reconcileInteractions.m index 31a221a1a..4ec04d594 100644 --- a/+labkit/+app/+internal/private/reconcileInteractions.m +++ b/+labkit/+app/+internal/private/reconcileInteractions.m @@ -546,7 +546,10 @@ function deleteEditor() options = spec.Options; options.onChanged = callback; kind = lower(spec.Kind); - if any(kind == ["scalebarreference", "scalebar"]) + if kind == "pairedanchors" + options.mode = "points"; + options.closed = false; + elseif any(kind == ["scalebarreference", "scalebar"]) options.closed = false; options.style = "Straight lines"; options.maxPoints = 2; diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m index 5acb34104..b4e34eac8 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m @@ -1,8 +1,8 @@ -function [alignedImage, tformRigid, method] = autoAlignMovingToReference(referenceImage, movingImage) +function [alignedImage, tformRigid, method, quality] = autoAlignMovingToReference(referenceImage, movingImage) %AUTOALIGNMOVINGTOREFERENCE Estimate and apply a rigid transform. % % Usage: -% [alignedImage, transform, method] = ... +% [alignedImage, transform, method, quality] = ... % dic_preprocess.analysisRun.autoAlignMovingToReference( ... % referenceImage, movingImage) % @@ -17,17 +17,23 @@ % tformRigid - Three-by-three row-vector homogeneous rigid transform, % shown as transform in the usage syntax. % method - Character vector identifying the fixed coarse-to-fine method. +% quality - Scalar structure containing angleDegrees, translationX, +% translationY, and score for the accepted structural match. % % Description: % Each image is converted to normalized grayscale independently. The search -% covers -30 through +30 degrees at three-degree spacing and refines the best -% neighborhood at half-degree spacing. Each candidate evaluates a toolbox- -% free, zero-padded phase-correlation translation on a response-limited -% preview. The accepted rotation and translation are then applied to the -% original moving image. Scale and deformation are not estimated; repeated -% texture and large nonoverlap can still produce a poor fit. +% covers -30 through +30 degrees at 1.5-degree spacing and refines the best +% neighborhood at quarter-degree spacing. Each candidate evaluates a +% toolbox-free, zero-padded phase-correlation translation on a bounded +% preview, then ranks the transform using oriented image structure at a +% finer resolution to avoid alias-driven angle selection. The accepted +% rotation and translation are applied to the original moving image. Scale +% and deformation are not estimated; repeated texture and large nonoverlap +% can still produce a poor fit. % % Failure Behavior: +% dic_preprocess:AutoAlignmentFailed - No candidate has a finite oriented +% structural match, including uniform or otherwise uninformative pairs. % The function does not assign a confidence score or reject an ambiguous % registration peak; low-texture or repeated-pattern inputs can return a % numerically valid but poor transform. Empty arrays, unsupported image @@ -49,10 +55,11 @@ fixedGray = normalizeGray(referenceImage); movingGray = normalizeGray(movingImage); - tformRigid = estimateRigidTransform(fixedGray, movingGray); + [tformRigid, quality] = estimateRigidTransform(fixedGray, movingGray); alignedImage = dic_preprocess.analysisRun.applyRigidTransform( ... referenceImage, movingImage, tformRigid); - method = 'toolbox-free coarse-to-fine rigid phase-correlation registration'; + method = ['toolbox-free coarse-to-fine rigid phase-correlation ' ... + 'registration with fine structural scoring']; end function gray = normalizeGray(imageData) @@ -74,44 +81,61 @@ end end -function transform = estimateRigidTransform(fixedGray, movingGray) +function [transform, quality] = estimateRigidTransform(fixedGray, movingGray) % DIC camera repositioning is expected to be modest. Searching this % bounded range and two resolution stages keep interactive registration % responsive while restoring the rotation capability lost by the - % translation-only fallback. A 256-pixel preview bounds each candidate's - % work without changing the source-resolution output transform. + % translation-only fallback. A 256-pixel preview bounds translation work; + % a finer 1024-pixel structural score avoids selecting angles from an + % aliased DIC texture preview without changing the source-resolution + % output transform. maximumExpectedRotationDegrees = 30; - coarseAngleStepDegrees = 3; - fineAngleStepDegrees = .5; + coarseAngleStepDegrees = 1.5; + fineAngleStepDegrees = .25; maximumPreviewDimension = 256; + maximumScoreDimension = 1024; fixedSize = [size(fixedGray, 1), size(fixedGray, 2)]; movingSize = [size(movingGray, 1), size(movingGray, 2)]; sampleStep = max(1, ceil(max([fixedSize, movingSize]) / ... maximumPreviewDimension)); fixedRows = 1:sampleStep:size(fixedGray, 1); fixedCols = 1:sampleStep:size(fixedGray, 2); + scoreStep = max(1, ceil(max([fixedSize, movingSize]) / ... + maximumScoreDimension)); + scoreRows = 1:scoreStep:size(fixedGray, 1); + scoreCols = 1:scoreStep:size(fixedGray, 2); fixedPreview = fixedGray(fixedRows, fixedCols); fixedFeature = registrationFeature(fixedPreview); coarseAngles = -maximumExpectedRotationDegrees: ... coarseAngleStepDegrees:maximumExpectedRotationDegrees; [bestTransform, bestAngle, bestScore] = bestCandidate( ... coarseAngles, fixedGray, movingGray, fixedFeature, ... - fixedRows, fixedCols, sampleStep); + fixedRows, fixedCols, sampleStep, scoreRows, scoreCols); fineAngles = bestAngle + ... (-coarseAngleStepDegrees:fineAngleStepDegrees:coarseAngleStepDegrees); fineAngles = fineAngles(abs(fineAngles) <= maximumExpectedRotationDegrees); [fineTransform, ~, fineScore] = bestCandidate( ... fineAngles, fixedGray, movingGray, fixedFeature, ... - fixedRows, fixedCols, sampleStep); + fixedRows, fixedCols, sampleStep, scoreRows, scoreCols); if fineScore > bestScore bestTransform = fineTransform; + bestScore = fineScore; + end + if ~isfinite(bestScore) + error("dic_preprocess:AutoAlignmentFailed", ... + "Automatic alignment could not find a finite structural match."); end transform = bestTransform; + quality = struct( ... + "angleDegrees", atan2d(transform(1, 2), transform(1, 1)), ... + "translationX", transform(3, 1), ... + "translationY", transform(3, 2), ... + "score", bestScore); end function [bestTransform, bestAngle, bestScore] = bestCandidate( ... angles, fixedGray, movingGray, fixedFeature, ... - fixedRows, fixedCols, sampleStep) + fixedRows, fixedCols, sampleStep, scoreRows, scoreCols) fixedCenter = ([size(fixedGray, 2), size(fixedGray, 1)] + 1) / 2; movingCenter = ([size(movingGray, 2), size(movingGray, 1)] + 1) / 2; bestScore = -inf; @@ -129,8 +153,9 @@ translation = centerTranslation + ... sampleStep * [colShift rowShift]; warped = warpPreview( ... - movingGray, rotation, translation, fixedRows, fixedCols); - score = alignmentScore(fixedGray(fixedRows, fixedCols), warped); + movingGray, rotation, translation, scoreRows, scoreCols); + score = orientedAlignmentScore( ... + fixedGray(scoreRows, scoreCols), warped); if score > bestScore bestScore = score; bestAngle = angle; @@ -211,3 +236,18 @@ end score = (fixedValues.' * movingValues) / denominator; end + +function score = orientedAlignmentScore(fixedImage, movingImage) + fixedHorizontal = [diff(fixedImage, 1, 2), ... + zeros(size(fixedImage, 1), 1)]; + fixedVertical = [diff(fixedImage, 1, 1); ... + zeros(1, size(fixedImage, 2))]; + movingHorizontal = [diff(movingImage, 1, 2), ... + zeros(size(movingImage, 1), 1)]; + movingVertical = [diff(movingImage, 1, 1); ... + zeros(1, size(movingImage, 2))]; + horizontalScore = alignmentScore( ... + fixedHorizontal, movingHorizontal); + verticalScore = alignmentScore(fixedVertical, movingVertical); + score = mean([horizontalScore, verticalScore]); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m index d200689d2..c4f39fe9d 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m @@ -7,7 +7,8 @@ return; end try - [~, transform, method] = dic_preprocess.analysisRun.autoAlignMovingToReference( ... + [~, transform, method, quality] = ... + dic_preprocess.analysisRun.autoAlignMovingToReference( ... cache.currentReferenceImage, cache.currentMovingImage); catch ME context.log("error", "dic_preprocess.analysisrun.runautomaticregistration.exception", 'Automatic alignment', ... @@ -17,5 +18,6 @@ end state = dic_preprocess.analysisRun.recordAlignment(state, transform, "automatic alignment"); context.log("info", "dic_preprocess.analysisrun.runautomaticregistration.status", ... - "Automatically aligned current pair using " + string(method) + "."); + "Automatically aligned current pair using " + string(method) + ".", ... + Category="workflow", Audience="user", Attributes=quality); end diff --git a/docs/apps/dic/dic-preprocess/README.md b/docs/apps/dic/dic-preprocess/README.md index 6b68d57c8..7ac2605d7 100644 --- a/docs/apps/dic/dic-preprocess/README.md +++ b/docs/apps/dic/dic-preprocess/README.md @@ -64,10 +64,11 @@ the same feature in the moving image. Repeat this reference/moving order for at least two complete pairs. Numbered markers show correspondence and the preview subtitle states which image expects the next point. -Drag an existing marker to refine it. **Undo point pair** removes the newest -complete pair. **Cancel point matching** discards the pending point set without -changing the current images. **Apply point alignment** estimates and applies a -rigid two-dimensional transform. +Markers are displayed as numbered points without connecting lines. Drag an +existing marker to refine it. **Undo point pair** removes the newest complete +pair. **Cancel point matching** discards the pending point set without changing +the current images. **Apply point alignment** estimates and applies a rigid +two-dimensional transform. Manual alignment uses all pairs in a least-squares rotation-and-translation fit. It does not estimate scale or shear and prevents a reflected solution. @@ -77,13 +78,16 @@ clustered points provide weak rotational leverage. ## Automatic Alignment **Auto align current pair** runs the app-owned base-MATLAB rigid-registration -path. It searches rotations from -30 to +30 degrees, refines the best angle, -and estimates translation with zero-padded, amplitude-weighted phase -correlation. It returns the same aligned image and rigid-transform fields as -manual alignment. Automatic alignment is a starting estimate, not a guarantee -of DIC-quality correspondence. Always inspect the false-color overlay and -prefer manual points when rotation exceeds the search range or the image has -repeated texture, large occlusion, scale change, deformation, or weak contrast. +path. It searches rotations from -30 to +30 degrees, estimates translation +with zero-padded amplitude-weighted phase correlation, and ranks candidate +angles using oriented structure on a finer preview so subsampled DIC texture +cannot dominate the decision through aliasing. Its diagnostic event records +the accepted angle, translation, and structural score. It returns the same +aligned image and rigid-transform fields as manual alignment. Automatic +alignment remains a starting estimate, not a guarantee of DIC-quality +correspondence. Always inspect the false-color overlay and prefer manual points +when rotation exceeds the search range or the image has repeated texture, +large occlusion, scale change, deformation, or weak contrast. ## Crop ROI diff --git a/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md b/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md index 2100c3e83..26c471ac4 100644 --- a/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md +++ b/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md @@ -7,6 +7,7 @@ sequence: 167 type: fix compatibility: compatible component: `labkit_DICPreprocess_app` | `1.7.1 -> 1.7.2` +component: `labkit.app` scope: DIC preprocess interaction repair scope: Rigid image registration ``` @@ -27,6 +28,9 @@ for mask boundaries, make matching and crop preview transitions explicit, and use the plot revision contract when the crop changes the image domain. Restore automatic rigid behavior with a bounded coarse-to-fine rotation search and amplitude-weighted, zero-padded phase correlation implemented in base MATLAB. +Score candidate transforms at a finer structural resolution so DIC texture +aliasing cannot select a plausible but wrong angle. Keep paired matching as a +point interaction with no implied path between anchors. ## Changes @@ -35,14 +39,18 @@ amplitude-weighted, zero-padded phase correlation implemented in base MATLAB. fits both axes to the new pixel domain. - Mask boundaries use a variable closed anchor path and can enter edit mode from an empty boundary. -- Automatic alignment estimates rotation and translation over a response- - limited preview before applying the accepted transform at source resolution. +- Paired match anchors render only numbered points and never a connecting line. +- Automatic alignment estimates translation on a response-limited preview, + scores rotation candidates with finer oriented structure, applies the + accepted transform at source resolution, and records its numeric decision + details in the diagnostic event. ## User and data impact -Users can activate mask editing, compare the same crop on both images, and see -the cropped image without stale white margins. Automatic alignment is more -useful for camera motion that includes rotation. Existing project fields, +Users can activate mask editing, compare the same crop on both images, see the +cropped image without stale white margins, and inspect uncluttered matched +points. Automatic alignment is more reliable for camera motion that includes +rotation and high-frequency DIC texture. Existing project fields, saved images, masks, coordinate conventions, and export schemas are unchanged. ## Compatibility and migration @@ -54,11 +62,14 @@ three-by-three row-vector transform shape. ## Validation -Focused scientific evidence covers existing integer translation and a -controlled rotation-plus-translation case without optional Toolboxes. The -hidden-GUI workflow covers moving-preview selection, crop overlays on both -axes, fitted crop limits, successful mask activation, export, and project -restore. +Focused scientific evidence covers existing integer translation, a controlled +rotation-plus-translation case, and finite transform-quality details without +optional Toolboxes. The hidden-GUI workflow covers moving-preview selection, +point-only paired anchors, numeric automatic-registration diagnostics, crop +overlays on both axes, fitted crop limits, successful mask activation, export, +and project restore. The supplied state/source pair confirmed the previous +automatic estimate disagreed materially with two stable manual fits and that +finer structural scoring recovered the same rotation neighborhood. ## Evidence diff --git a/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m b/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m index 49e40149b..1b96cc3e8 100644 --- a/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m +++ b/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m @@ -65,7 +65,7 @@ function reducesControlledRotationAndTranslationWithoutAToolbox(testCase) moving = dic_preprocess.analysisRun.applyRigidTransform( ... reference, reference, inv(expected)); - [aligned, transform, method] = ... + [aligned, transform, method, quality] = ... dic_preprocess.analysisRun.autoAlignMovingToReference( ... reference, moving); @@ -75,6 +75,18 @@ function reducesControlledRotationAndTranslationWithoutAToolbox(testCase) testCase.verifyLessThan(norm(transform(1:2, 1:2) - ... expected(1:2, 1:2), "fro"), .05); testCase.verifySubstring(method, 'rigid'); + testCase.verifyEqual(quality.angleDegrees, ... + atan2d(transform(1, 2), transform(1, 1)), AbsTol=1e-12); + testCase.verifyEqual(quality.translationX, transform(3, 1)); + testCase.verifyEqual(quality.translationY, transform(3, 2)); + testCase.verifyTrue(isfinite(quality.score)); + end + + function rejectsPairsWithoutFiniteRegistrationStructure(testCase) + testCase.verifyError(@() ... + dic_preprocess.analysisRun.autoAlignMovingToReference( ... + zeros(32), zeros(32)), ... + "dic_preprocess:AutoAlignmentFailed"); end end end diff --git a/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m b/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m index f88befc30..68fd87f81 100644 --- a/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m +++ b/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m @@ -24,10 +24,22 @@ function alignsCropsExportsAndRestoresASyntheticPair(testCase) testCase.verifyEqual( ... runtime.State.project.parameters.previewMode, ... "Current moving image"); - runtime.invokeAction("autoAlign"); - runtime.invokeAction("startCropRoi"); + runtime.applyInteraction("matchPoints", ... + "interactionChanged", ... + {[20 15; 30 24], [17 17; 27 26]}); referenceAxes = findall(figureValue, "Tag", "preview.reference"); movingAxes = findall(figureValue, "Tag", "preview.moving"); + testCase.verifyFalse(hasVisibleConnectingLine(referenceAxes)); + testCase.verifyFalse(hasVisibleConnectingLine(movingAxes)); + runtime.invokeAction("autoAlign"); + events = runtime.diagnosticEvents(); + event = events(string({events.eventName}) == ... + "dic_preprocess.analysisrun.runautomaticregistration.status"); + testCase.verifyNumElements(event, 1); + testCase.verifyEqual(sort(string(fieldnames(event.attributes))), ... + sort(["angleDegrees"; "score"; ... + "translationX"; "translationY"])); + runtime.invokeAction("startCropRoi"); overlayTag = "labkitDicPreprocessPreviewOverlay"; testCase.verifyNotEmpty(findall(referenceAxes, "Tag", overlayTag)); testCase.verifyNotEmpty(findall(movingAxes, "Tag", overlayTag)); @@ -60,6 +72,19 @@ function alignsCropsExportsAndRestoresASyntheticPair(testCase) end end +function tf = hasVisibleConnectingLine(ax) +lines = findall(ax, "Type", "line"); +tf = false; +for index = 1:numel(lines) + x = double(lines(index).XData); + if string(lines(index).LineStyle) ~= "none" && ... + nnz(isfinite(x)) >= 2 + tf = true; + return; + end +end +end + function writePair(referencePath, movingPath) reference = zeros(40, 48, "uint8"); reference(12:25, 20:33) = 255; From d057a9753659e5b9d63de4df011e15c4a5073fbd Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 14:30:50 -0500 Subject: [PATCH 08/32] feat: add explicit sensitive diagnostic export --- .../MatlabPlatformAdapter.m | 63 ++++++-- .../installUtilityMenus.m | 4 +- +labkit/+app/+internal/RuntimeKernel.m | 137 ++++++++++++++---- .../+app/+internal/SessionDiagnosticBundle.m | 76 ++++++++-- +labkit/+app/+internal/SessionDiagnostics.m | 7 +- docs/framework/README.md | 12 +- ...session-log-levels-and-automatic-export.md | 35 +++-- tests/specs/labkit/app/AppSdkSpec.m | 86 +++++++++++ .../labkit/app/SessionDiagnosticBundleSpec.m | 48 +++++- tests/specs/labkit/app/SessionLogViewerSpec.m | 20 ++- 10 files changed, 406 insertions(+), 82 deletions(-) diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m index 48311c7a6..f02fe6f9d 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -245,8 +245,20 @@ function failStartup(obj, cause) drawnow limitrate nocallbacks end - function alert(obj, message, title) - uialert(obj.Figure, char(string(message)), char(string(title))); + function alert(obj, message, title, icon) + if nargin < 4 + icon = "error"; + end + if string(obj.Figure.Visible) == "off" && ... + labkit.app.internal.NativeAdapterValues.startupGuiMode() == ... + "hidden" + setappdata(obj.Figure, "labkitAppLastAlert", struct( ... + "message", string(message), "title", string(title), ... + "icon", string(icon))); + return; + end + uialert(obj.Figure, char(string(message)), char(string(title)), ... + Icon=char(string(icon))); end function result = chooseOption(obj, prompt, choices, title, ... @@ -488,12 +500,24 @@ function saveAllPlots(obj) end function saveScreenshot(obj) - choice = obj.chooseOutputFile( ... - {"*.png", "PNG image (*.png)"; ... - "*.pdf", "PDF file (*.pdf)"}, "app.png"); - if ~choice.Cancelled - exportapp(obj.Figure, choice.Value); + filename = obj.Runtime.automaticArtifactFilename( ... + "screenshot", ".png"); + try + destination = obj.Runtime.automaticArtifactDestination( ... + "screenshots", "screenshot", ".png"); + exportapp(obj.Figure, destination); + catch + choice = obj.chooseOutputFile( ... + {"*.png", "PNG image (*.png)"; ... + "*.pdf", "PDF file (*.pdf)"}, filename); + if choice.Cancelled + return; + end + destination = string(choice.Value); + exportapp(obj.Figure, destination); end + obj.alert("Screenshot written to:" + newline + destination, ... + "Screenshot Saved", "info"); end function copyScreenshot(obj) @@ -510,16 +534,23 @@ function copyScreenshot(obj) end function saveState(obj) - metadata = obj.Runtime.documentMetadata(); - startPath = string(metadata.path); - if strlength(startPath) == 0 - startPath = "project.mat"; - end - choice = obj.chooseOutputFile( ... - {"*.mat", "LabKit project (*.mat)"}, startPath); - if ~choice.Cancelled - obj.Runtime.saveProject(obj.Runtime.State, choice.Value); + filename = obj.Runtime.automaticArtifactFilename( ... + "state", ".mat"); + try + destination = obj.Runtime.automaticArtifactDestination( ... + "states", "state", ".mat"); + obj.Runtime.saveProject(obj.Runtime.State, destination); + catch + choice = obj.chooseOutputFile( ... + {"*.mat", "LabKit project (*.mat)"}, filename); + if choice.Cancelled + return; + end + destination = string(choice.Value); + obj.Runtime.saveProject(obj.Runtime.State, destination); end + obj.alert("App state written to:" + newline + destination, ... + "State Saved", "info"); end function loadState(obj) diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m index 5e8fdfb98..68c8f471b 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m @@ -43,7 +43,7 @@ function installUtilityMenus(obj) Tag="labkitAppUtilityCopyScreenshot", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.copyScreenshot())); - uimenu(screenshotMenu, Text="Save to File...", ... + uimenu(screenshotMenu, Text="Save to Artifacts", ... Tag="labkitAppUtilityScreenshot", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.saveScreenshot())); @@ -51,7 +51,7 @@ function installUtilityMenus(obj) if obj.hasProjectDocument() projectMenu = uimenu(toolsMenu, Text="Project State", ... Tag="labkitAppUtilityProjectStateMenu"); - uimenu(projectMenu, Text="Save State...", ... + uimenu(projectMenu, Text="Save State", ... Tag="labkitAppUtilitySaveState", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.saveState())); diff --git a/+labkit/+app/+internal/RuntimeKernel.m b/+labkit/+app/+internal/RuntimeKernel.m index 471aa472c..528db47e6 100644 --- a/+labkit/+app/+internal/RuntimeKernel.m +++ b/+labkit/+app/+internal/RuntimeKernel.m @@ -184,13 +184,23 @@ function setTraceCapture(obj, enabled) obj.Recorder.setTraceEnabled(enabled); end - function destination = exportDiagnosticBundle(obj, destination) + function destination = exportDiagnosticBundle( ... + obj, destination, includePrivateState) + if nargin < 3 + includePrivateState = false; + end + includePrivateState = logicalScalar( ... + includePrivateState, "includePrivateState"); operation = obj.Recorder.begin( ... "runtime.lifecycle", "diagnostics.bundle_exported", ... "Exporting diagnostic bundle."); try + privateState = []; + if includePrivateState + privateState = obj.State; + end destination = obj.Recorder.exportBundle( ... - destination, operation.Id); + destination, operation.Id, privateState); obj.Recorder.finish( ... operation, "completed", "notApplicable", []); catch cause @@ -202,17 +212,30 @@ function setTraceCapture(obj, enabled) end function destination = exportDiagnosticBundleInteractive(obj) + selection = obj.Context.chooseOption( ... + ["Choose the diagnostic detail to export. Complete logs " + ... + "include current App state and may contain paths, " + ... + "scientific values, results, and decoded images."], ... + ["Redacted log", "Complete log (sensitive)", "Cancel"], ... + Title="Export Diagnostic Bundle", ... + DefaultChoice="Redacted log", CancelChoice="Cancel"); + if selection.Cancelled || selection.Value == "Cancel" + destination = ""; + return; + end + includePrivateState = ... + selection.Value == "Complete log (sensitive)"; destination = ""; try automaticDestination = ... - obj.automaticDiagnosticDestination(); + obj.automaticDiagnosticDestination(includePrivateState); destination = obj.exportDiagnosticBundle( ... - automaticDestination); + automaticDestination, includePrivateState); if endsWith(destination, ".txt", ... IgnoreCase=true) obj.alertDiagnosticTextFallback(destination); else - obj.Context.alert( ... + obj.notifyUser( ... "Diagnostic bundle written to:" + newline + ... string(destination), ... "Diagnostic Bundle Exported"); @@ -220,7 +243,7 @@ function setTraceCapture(obj, enabled) return; catch automaticFailure fallbackName = diagnosticFallbackName( ... - obj.automaticDiagnosticFilename()); + obj.automaticDiagnosticFilename(includePrivateState)); end choice = obj.Context.chooseOutputFile( ... {"*.txt", "Diagnostic text fallback (*.txt)"}, ... @@ -285,6 +308,40 @@ function alertDiagnosticTextFallback(obj, destination) "Synthetic Inputs"); end + function destination = automaticArtifactDestination( ... + obj, category, stem, extension) + category = artifactToken(category, "category"); + filename = obj.automaticArtifactFilename(stem, extension); + folder = artifactFolder(category); + if exist(char(folder), "dir") ~= 7 + [created, message] = mkdir(char(folder)); + if ~created + error("labkit:app:runtime:ArtifactWriteFailed", ... + "Could not create the LabKit artifacts folder: %s", ... + message); + end + end + destination = fullfile(folder, filename); + end + + function filename = automaticArtifactFilename( ... + obj, stem, extension) + stem = artifactToken(stem, "stem"); + extension = string(extension); + if ~isscalar(extension) || ... + isempty(regexp(char(extension), '^\.[a-z0-9]+$', "once")) + error("labkit:app:runtime:InvariantFailure", ... + "Artifact extension must be a lowercase file extension."); + end + appId = artifactToken(obj.Application.AppId, "App ID"); + timestamp = string(datetime("now", TimeZone="UTC", ... + Format="yyyyMMdd-HHmmss")); + nonce = extractBefore( ... + string(java.util.UUID.randomUUID()), 9); + filename = "labkit-" + stem + "-" + appId + "-" + ... + timestamp + "-" + nonce + extension; + end + function figure = figureHandle(obj) if ~isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") error("labkit:app:runtime:InvariantFailure", ... @@ -1093,30 +1150,26 @@ function markDocumentChanged(obj) obj.refreshWindowTitle(); end - function destination = automaticDiagnosticDestination(obj) - folder = diagnosticArtifactsFolder(); - if exist(char(folder), "dir") ~= 7 - [created, message] = mkdir(char(folder)); - if ~created - error("labkit:app:runtime:DiagnosticWriteFailed", ... - "Could not create the diagnostic artifacts folder: %s", ... - message); - end - end - destination = fullfile( ... - folder, obj.automaticDiagnosticFilename()); + function destination = automaticDiagnosticDestination( ... + obj, includePrivateState) + stem = diagnosticArtifactStem(includePrivateState); + destination = obj.automaticArtifactDestination( ... + "diagnostics", stem, ".zip"); end - function filename = automaticDiagnosticFilename(obj) - appId = regexprep(lower(obj.Application.AppId), ... - "[^a-z0-9]+", "-"); - appId = regexprep(appId, "(^-+|-+$)", ""); - timestamp = string(datetime("now", TimeZone="UTC", ... - Format="yyyyMMdd-HHmmss")); - nonce = extractBefore( ... - string(java.util.UUID.randomUUID()), 9); - filename = "labkit-diagnostics-" + appId + "-" + ... - timestamp + "-" + nonce + ".zip"; + function filename = automaticDiagnosticFilename( ... + obj, includePrivateState) + filename = obj.automaticArtifactFilename( ... + diagnosticArtifactStem(includePrivateState), ".zip"); + end + + function notifyUser(obj, message, title) + if isa(obj.Adapter, ... + "labkit.app.internal.MatlabPlatformAdapter") + obj.Adapter.alert(message, title, "info"); + else + obj.Context.alert(message, title); + end end function refreshWindowTitle(obj) @@ -1171,12 +1224,36 @@ function assertProjectStore(obj) end end -function folder = diagnosticArtifactsFolder() +function folder = artifactFolder(category) folder = string(fileparts(mfilename("fullpath"))); for index = 1:3 folder = string(fileparts(folder)); end -folder = fullfile(folder, "artifacts", "diagnostics"); +folder = fullfile(folder, "artifacts", category); +end + +function value = artifactToken(value, label) +value = regexprep(lower(string(value)), "[^a-z0-9]+", "-"); +value = regexprep(value, "(^-+|-+$)", ""); +if ~isscalar(value) || strlength(value) == 0 + error("labkit:app:runtime:InvariantFailure", ... + "Artifact %s must contain an alphanumeric token.", label); +end +end + +function stem = diagnosticArtifactStem(includePrivateState) +if includePrivateState + stem = "diagnostics-sensitive"; +else + stem = "diagnostics"; +end +end + +function value = logicalScalar(value, name) +if ~(islogical(value) && isscalar(value)) + error("labkit:app:contract:InvalidValue", ... + "%s must be one logical value.", name); +end end function filename = diagnosticFallbackName(zipFilename) diff --git a/+labkit/+app/+internal/SessionDiagnosticBundle.m b/+labkit/+app/+internal/SessionDiagnosticBundle.m index 6b6d8f8d6..e57ed6bbb 100644 --- a/+labkit/+app/+internal/SessionDiagnosticBundle.m +++ b/+labkit/+app/+internal/SessionDiagnosticBundle.m @@ -1,11 +1,21 @@ classdef (Hidden, Sealed) SessionDiagnosticBundle - %SESSIONDIAGNOSTICBUNDLE Write one privacy-safe diagnostic ZIP snapshot. - % SessionDiagnostics supplies canonical events and manifest metadata. - % This writer never receives App state, source files, results, or images. + %SESSIONDIAGNOSTICBUNDLE Write one diagnostic ZIP snapshot. + % SessionDiagnostics supplies canonical privacy-safe events and manifest + % metadata. Runtime may additionally supply one explicitly user-authorized + % App state value; safe export remains the default. methods (Static) - function destination = write(snapshot, destination) + function destination = write(snapshot, destination, privateState) + if nargin < 3 + privateState = []; + end snapshot = validateSnapshot(snapshot); + includesPrivateState = ~isempty(privateState); + if includesPrivateState && ... + (~isstruct(privateState) || ~isscalar(privateState)) + error("labkit:app:runtime:InvariantFailure", ... + "Private diagnostic App state must be one scalar struct."); + end destination = diagnosticZipPath(destination); parent = string(fileparts(destination)); if strlength(parent) == 0 @@ -20,7 +30,7 @@ mkdir(char(staging)); cleanup = onCleanup(@() removeStaging(staging)); writeText(fullfile(staging, "README.txt"), ... - readmeLines(snapshot)); + readmeLines(snapshot, includesPrivateState)); writeJson(fullfile(staging, "manifest.json"), ... snapshot.manifest); writeEvents(fullfile(staging, "events.jsonl"), ... @@ -30,7 +40,11 @@ writeJson(fullfile(staging, "errors.json"), ... errorRecords(snapshot.events)); writeJson(fullfile(staging, "redaction-report.json"), ... - redactionReport(snapshot)); + redactionReport(snapshot, includesPrivateState)); + if includesPrivateState + writePrivateState(fullfile(staging, "app-state.mat"), ... + privateState); + end temporaryZip = string(tempname(char(parent))) + ".zip"; zipCleanup = onCleanup(@() removeFile(temporaryZip)); @@ -42,6 +56,9 @@ "errors.json" "redaction-report.json" ]; + if includesPrivateState + files(end + 1, 1) = "app-state.mat"; + end zip(char(temporaryZip), cellstr(files), char(staging)); [moved, message] = movefile( ... char(temporaryZip), char(destination), "f"); @@ -159,14 +176,13 @@ function validateRecord(record) end end -function value = readmeLines(snapshot) +function value = readmeLines(snapshot, includesPrivateState) capture = snapshot.capture; degradation = snapshot.degradation; value = [ "LabKit Diagnostic Bundle" "" - "This bundle contains privacy-safe Runtime session records only." - "It does not contain projects, scientific inputs or results, images, screenshots, or source files." + privacyDescription(includesPrivateState) "" "Capture notes:" "- TRACE enabled at export: " + yesNo(capture.traceEnabled) @@ -185,6 +201,21 @@ function validateRecord(record) ]; end +function value = privacyDescription(includesPrivateState) +if includesPrivateState + value = [ ... + "Sensitive export was explicitly enabled by the user." + "app-state.mat contains the current App project and session state and may include paths, filenames, scientific values, results, and decoded images." + "External source files and screenshots are not copied into this bundle." + ]; +else + value = [ ... + "This bundle contains privacy-safe Runtime session records only." + "It does not contain projects, scientific inputs or results, images, screenshots, or source files." + ]; +end +end + function value = fallbackLines(snapshot) application = snapshot.application; capture = snapshot.capture; @@ -301,12 +332,13 @@ function validateRecord(record) "rootActionId", "", "exception", struct()); end -function value = redactionReport(snapshot) -value = struct( ... - "schemaVersion", 1, ... - "privacyBoundary", "validated-before-retention", ... - "exportProjection", "canonical-safe-events-only", ... - "excludedData", [ ... +function value = redactionReport(snapshot, includesPrivateState) +if includesPrivateState + projection = "canonical-safe-events-plus-opt-in-app-state"; + excluded = ["screenshots"; "source-files"]; +else + projection = "canonical-safe-events-only"; + excluded = [ ... "paths" "filenames" "input-content" @@ -316,11 +348,23 @@ function validateRecord(record) "images" "screenshots" "source-files" - ], ... + ]; +end +value = struct( ... + "schemaVersion", 1, ... + "privacyBoundary", "validated-before-retention", ... + "exportProjection", projection, ... + "includesPrivateAppState", includesPrivateState, ... + "excludedData", excluded, ... "removedValueCount", 0, ... "degradation", snapshot.degradation); end +function writePrivateState(filepath, privateState) +applicationState = privateState; +save(char(filepath), "applicationState", "-mat"); +end + function writeEvents(filepath, events) file = fopen(char(filepath), "w", "n", "UTF-8"); if file < 0 diff --git a/+labkit/+app/+internal/SessionDiagnostics.m b/+labkit/+app/+internal/SessionDiagnostics.m index 79d9b6d5e..d8f376ec0 100644 --- a/+labkit/+app/+internal/SessionDiagnostics.m +++ b/+labkit/+app/+internal/SessionDiagnostics.m @@ -101,10 +101,13 @@ function setTraceEnabled(obj, enabled) end function destination = exportBundle( ... - obj, destination, excludeOperationId) + obj, destination, excludeOperationId, privateState) if nargin < 3 excludeOperationId = ""; end + if nargin < 4 + privateState = []; + end obj.Journal.flush(); streamSnapshot = obj.Stream.captureSnapshot(); manifest = obj.Journal.manifest(); @@ -141,7 +144,7 @@ function setTraceEnabled(obj, enabled) "degradation", degradation, "capture", capture); destination = ... labkit.app.internal.SessionDiagnosticBundle.write( ... - snapshot, destination); + snapshot, destination, privateState); end function destination = exportTextFallback( ... diff --git a/docs/framework/README.md b/docs/framework/README.md index 67bcc4b09..cb382e9cd 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -152,9 +152,15 @@ utilities do not compete with the App's workflow controls: - **Plots** opens, copies, or saves the App's plot surfaces. - **Screenshot** copies the complete App surface to the system clipboard or - saves it to an image file. -- **Project State** saves or loads the current project document when the App - declares a project schema. + writes a uniquely named PNG beneath `artifacts/screenshots/`. A save dialog + is used only if automatic artifact output fails. +- **Project State** writes a uniquely named project beneath + `artifacts/states/` or loads a selected project when the App declares a + project schema. A save dialog is used only if automatic output fails. +- **Diagnostics** opens the App-named Session Log or exports a uniquely named + bundle beneath `artifacts/diagnostics/`. Every export asks whether to write + a redacted log or a complete sensitive log containing current App state; + redacted is the default. These actions are framework-owned native behavior. Apps do not declare menu items, implement clipboard integration, or duplicate project persistence diff --git a/docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md b/docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md index 6fd42defd..93dd7fed1 100644 --- a/docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md +++ b/docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md @@ -9,6 +9,7 @@ compatibility: compatible component: `labkit.app` | `2.1.0 -> 2.2.0` scope: Session Log detail levels scope: Automatic diagnostic export +scope: Automatic utility artifacts ``` ## Context @@ -17,7 +18,9 @@ The standard Session Log combined a severity selector with a second audience view, presented an unexplained Default choice, and exposed a pause-follow control that did not improve diagnosis. TRACE normally contained no more useful detail than DEBUG because capture was off and Runtime emitted few trace stages. -Diagnostic ZIP export also asked for a destination before every attempt. +Diagnostic ZIP export also asked for a destination before every attempt, while +screenshot and project-state saves required manual naming and destination +selection. Successful diagnostic export used MATLAB's default error icon. ## Decision and rationale @@ -25,8 +28,11 @@ Use one three-level display contract while keeping capture cost independent of the selected view. Retain DEBUG and higher during ordinary operation, enable TRACE automatically after the first error, and keep an explicit capture toggle inside the owning log window. Give TRACE distinct transaction and presentation -stage records. Treat the repository artifacts area as the first diagnostic -destination and ask for another location only after automatic recovery fails. +stage records. Treat the repository artifacts area as the first destination +for diagnostics, screenshots, and project state, generate App-specific names, +and ask for another location only after automatic output fails. Keep retained +events privacy-safe, but let each export explicitly choose between the redacted +bundle and an opt-in sensitive bundle containing current App state. ## Changes @@ -41,13 +47,21 @@ destination and ask for another location only after automatic recovery fails. - Diagnostic export generates a unique App-specific filename beneath `artifacts/diagnostics/`; its text fallback uses the same base name, and a prefilled save dialog appears only when automatic output fails. +- Each diagnostic export prompts for redacted or complete-sensitive content; + complete export adds the current project/session state as `app-state.mat`. +- Screenshot and project-state saves now generate App-specific names beneath + `artifacts/screenshots/` and `artifacts/states/`, with chooser fallback only + when automatic output fails. +- Successful utility exports use an information icon rather than MATLAB's + default error icon. ## User and data impact Users can distinguish concurrent App logs, select a meaningful amount of detail -with one control, and export diagnostics without choosing a path. Diagnostic -contents remain limited to validated privacy-safe Runtime records. Projects, -inputs, results, paths, filenames, images, and screenshots remain excluded. +with one control, and export diagnostics without choosing a path. Redacted +diagnostics remain limited to validated privacy-safe Runtime records. Complete +export is explicit and may contain projects, inputs, results, paths, filenames, +and decoded images; external source files and screenshots remain excluded. ## Compatibility and migration @@ -60,10 +74,11 @@ is available in the Session Log window. ## Validation Focused headless specifications cover three-level projection, automatic trace -activation, distinct trace stages, generated ZIP and fallback names, and the -privacy boundary. Hidden-GUI specifications cover App-specific titles, the -single level selector, viewer-local TRACE control, continuous follow, removed -duplicate controls, and exports from both entry points. +activation, distinct trace stages, generated ZIP and fallback names, redacted +default export, and explicit state-inclusive export. Hidden-GUI specifications +cover App-specific titles, the single level selector, viewer-local TRACE +control, continuous follow, removed duplicate controls, exports from both entry +points, and automatic screenshot/project-state artifacts. ## Evidence diff --git a/tests/specs/labkit/app/AppSdkSpec.m b/tests/specs/labkit/app/AppSdkSpec.m index 868a6249e..30372b42b 100644 --- a/tests/specs/labkit/app/AppSdkSpec.m +++ b/tests/specs/labkit/app/AppSdkSpec.m @@ -429,6 +429,51 @@ function exposesSyntheticInputGenerationAsAnOrdinaryTool(testCase) "Generate Synthetic Inputs..."); clear cleanup end + + function namesScreenshotTargetsAndSavesProjectStateToArtifacts(testCase) + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.field("gain", Kind="numeric", ... + Bind="project.parameters.gain")}); + app = AppSdkSpec.definition(layout, "ProjectSchema", ... + labkit.app.project.Schema( ... + Version=1, Create=@createProject, ... + Validate=@validateProject)); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + states = sdkArtifactFolder("states"); + beforeStates = artifactFiles( ... + states, "labkit-state-probe-app-*.mat"); + fileCleanup = onCleanup(@() deleteNewStateArtifacts( ... + states, beforeStates)); + figureValue = runtime.figureHandle(); + screenshotMenu = oneTagged( ... + figureValue, "labkitAppUtilityScreenshot"); + stateMenu = oneTagged( ... + figureValue, "labkitAppUtilitySaveState"); + + testCase.verifyEqual(string(screenshotMenu.Text), ... + "Save to Artifacts"); + testCase.verifyEqual(string(stateMenu.Text), "Save State"); + screenshotTarget = runtime.automaticArtifactDestination( ... + "screenshots", "screenshot", ".png"); + testCase.verifyTrue(contains(screenshotTarget, ... + fullfile("artifacts", "screenshots"))); + testCase.verifyTrue(endsWith(screenshotTarget, ".png")); + invokeMenu(stateMenu); + + afterStates = artifactFiles( ... + states, "labkit-state-probe-app-*.mat"); + testCase.verifyNumElements( ... + setdiff(afterStates, beforeStates), 1); + notice = getappdata(figureValue, "labkitAppLastAlert"); + testCase.verifyEqual(notice.title, "State Saved"); + testCase.verifyEqual(notice.icon, "info"); + clear fileCleanup cleanup + end end methods (Static, Access = private) @@ -449,6 +494,47 @@ function exposesSyntheticInputGenerationAsAnOrdinaryTool(testCase) end end +function handle = oneTagged(parent, tag) +handle = findall(parent, "Tag", tag); +assert(isscalar(handle), "Expected one handle tagged " + tag + "."); +end + +function invokeMenu(menu) +menu.MenuSelectedFcn(menu, []); +drawnow; +end + +function folder = sdkArtifactFolder(category) +folder = string(fileparts(which("labkit.app.internal.RuntimeKernel"))); +for index = 1:3 + folder = string(fileparts(folder)); +end +folder = fullfile(folder, "artifacts", category); +end + +function files = artifactFiles(folder, pattern) +if ~isfolder(folder) + files = strings(1, 0); + return; +end +entries = dir(fullfile(folder, pattern)); +files = string({entries.name}); +end + +function deleteNewStateArtifacts(stateFolder, beforeStates) +deleteArtifactSet(stateFolder, setdiff(artifactFiles( ... + stateFolder, "labkit-state-probe-app-*.mat"), beforeStates)); +end + +function deleteArtifactSet(folder, files) +for filename = files + filepath = fullfile(folder, filename); + if isfile(filepath) + delete(filepath); + end +end +end + function project = createProject() project = struct("parameters", struct("gain", 1)); end diff --git a/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m index f828d2eb7..b6d886d2f 100644 --- a/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m +++ b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m @@ -1,5 +1,5 @@ classdef SessionDiagnosticBundleSpec < matlab.unittest.TestCase - % SESSIONDIAGNOSTICBUNDLESPEC Regression: explicit diagnostic export produces one privacy-safe complete ZIP from the current ordinary session. + % SESSIONDIAGNOSTICBUNDLESPEC Specify redacted and opt-in sensitive bundles. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) function exportsTheExactSafeBundleFromAnActiveSession(testCase) @@ -89,6 +89,32 @@ function fallsBackToSafeMemoryWhenTheJournalIsUnavailable(testCase) clear cleanup end + function explicitlyIncludesCurrentAppStateOnlyWhenRequested(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = bundleDefinition(); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), [], JournalRoot=folder); + cleanup = onCleanup(@() runtime.close()); + + destination = runtime.exportDiagnosticBundle( ... + fullfile(folder, "sensitive.zip"), true); + unpacked = fullfile(folder, "sensitive"); + unzip(destination, unpacked); + saved = load(fullfile(unpacked, "app-state.mat")); + redaction = jsondecode(fileread( ... + fullfile(unpacked, "redaction-report.json"))); + + testCase.verifyTrue(isfield(saved, "applicationState")); + testCase.verifyEqual( ... + string(fieldnames(saved.applicationState)), ... + ["project"; "session"]); + testCase.verifyTrue(redaction.includesPrivateAppState); + testCase.verifyEqual(string(redaction.exportProjection), ... + "canonical-safe-events-plus-opt-in-app-state"); + clear cleanup + end + function writesOneReadableTextFallbackBesideTheAutomaticZip(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; @@ -126,8 +152,12 @@ function automaticExportUsesArtifactsAndDoesNotAskForAPath(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; definition = bundleDefinition(); + selection = containers.Map("KeyType", "char", ... + "ValueType", "any"); backend = struct( ... "chooseOutputFile", @failOutputDialog, ... + "choose", @(varargin) captureDiagnosticChoice( ... + selection, varargin{:}), ... "alert", @(~, ~) []); runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... definition, [], backend, [], JournalRoot=folder); @@ -144,6 +174,14 @@ function automaticExportUsesArtifactsAndDoesNotAskForAPath(testCase) testCase.verifyTrue(startsWith( ... string(filename) + string(extension), ... "labkit-diagnostics-probe-diagnostic-bundle-")); + testCase.verifyEqual(selection("choices"), ... + ["Redacted log", "Complete log (sensitive)", "Cancel"]); + testCase.verifyEqual(selection("default"), "Redacted log"); + testCase.verifyEqual(selection("cancel"), "Cancel"); + unpacked = fullfile(folder, "redacted-interactive"); + unzip(destination, unpacked); + testCase.verifyFalse(isfile( ... + fullfile(unpacked, "app-state.mat"))); clear fileCleanup cleanup end end @@ -217,3 +255,11 @@ function deleteIfFile(filepath) "Intentional output dialog failure."); throw(choice); end + +function choice = captureDiagnosticChoice( ... + store, ~, choices, ~, defaultChoice, cancelChoice) +store("choices") = string(choices); +store("default") = string(defaultChoice); +store("cancel") = string(cancelChoice); +choice = labkit.app.dialog.Choice(string(defaultChoice)); +end diff --git a/tests/specs/labkit/app/SessionLogViewerSpec.m b/tests/specs/labkit/app/SessionLogViewerSpec.m index 9e7b92b7e..47d29d697 100644 --- a/tests/specs/labkit/app/SessionLogViewerSpec.m +++ b/tests/specs/labkit/app/SessionLogViewerSpec.m @@ -145,7 +145,10 @@ function retainsOneNativeTableAcrossALargeIncrementalBurst(testCase) end function exportsTheLiveBundleFromToolsAndTheViewer(testCase) - backend = struct("alert", @(~, ~) []); + backend = struct( ... + "alert", @(~, ~) [], ... + "choose", @(varargin) labkit.app.dialog.Choice( ... + "Complete log (sensitive)")); runtime = viewerRuntime(testCase, backend); cleanup = onCleanup(@() runtime.close()); runtime.invokeAction("run"); @@ -172,6 +175,19 @@ function exportsTheLiveBundleFromToolsAndTheViewer(testCase) invoke(exportButton.ButtonPushedFcn, exportButton, []); afterViewer = diagnosticFiles(folder); testCase.verifyNumElements(setdiff(afterViewer, before), 2); + viewerFile = setdiff(afterViewer, [before menuFile]); + testCase.verifyNumElements(viewerFile, 1); + testCase.verifyTrue(contains(viewerFile, ... + "labkit-diagnostics-sensitive-probe-log-viewer-")); + unpacked = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + unzip(fullfile(folder, viewerFile), unpacked); + testCase.verifyTrue(isfile( ... + fullfile(unpacked, "app-state.mat"))); + notice = getappdata(appFigure, "labkitAppLastAlert"); + testCase.verifyEqual(notice.title, ... + "Diagnostic Bundle Exported"); + testCase.verifyEqual(notice.icon, "info"); records = runtime.diagnosticEvents(); testCase.verifyGreaterThanOrEqual(sum( ... string({records.eventName}) == ... @@ -255,7 +271,7 @@ function invoke(callback, varargin) function files = diagnosticFiles(folder) entries = dir(fullfile(folder, ... - "labkit-diagnostics-probe-log-viewer-*.zip")); + "labkit-diagnostics*-probe-log-viewer-*.zip")); files = string({entries.name}); end From c27ef9e9f4f9c4008bfe033118dbe2baf2e34e5a Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 14:42:48 -0500 Subject: [PATCH 09/32] fix: unify app file selection behavior --- .../MatlabPlatformAdapter.m | 7 ++- .../installFilePanelCallbacks.m | 24 ++++++---- +labkit/+app/+layout/fileList.m | 7 ++- .../+batch_crop/+sourceFiles/layoutSection.m | 4 +- .../batch_crop/+batch_crop/definition.m | 2 +- .../+thermalSources/layoutSection.m | 6 ++- .../+thermalSources/matchesRadiometricFiles.m | 11 +++++ .../flir_thermal/+flir_thermal/definition.m | 2 +- .../+focus_stack/+workbench/buildLayout.m | 2 +- .../focus_stack/+focus_stack/definition.m | 2 +- .../+image_enhance/+workbench/buildLayout.m | 2 +- .../image_enhance/+image_enhance/definition.m | 2 +- .../+image_match/+workbench/buildLayout.m | 2 +- .../image_match/+image_match/definition.m | 2 +- .../+sourceAxes/layoutSection.m | 4 +- .../figure_studio/+figure_studio/definition.m | 2 +- .../+rhs_preview/+sourceFiles/filterSection.m | 2 +- .../rhs_preview/+rhs_preview/definition.m | 2 +- .../image-measurement/flir-thermal/README.md | 10 ++-- docs/framework/README.md | 5 ++ ... LK-20260803-file-selection-validation.md} | 46 ++++++++++++++++--- .../AppDefinitionConformanceSpec.m | 20 ++++++++ .../thermalSources/FlirThermalSelectionSpec.m | 39 ++++++++++++++++ tests/specs/labkit/app/AppSdkSpec.m | 40 ++++++++++++++++ 24 files changed, 206 insertions(+), 39 deletions(-) create mode 100644 apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/matchesRadiometricFiles.m rename docs/history/records/2026/08/{LK-20260803-electrochem-batch-source-filtering.md => LK-20260803-file-selection-validation.md} (57%) create mode 100644 tests/specs/apps/image_measurement/flir_thermal/thermalSources/FlirThermalSelectionSpec.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m index f02fe6f9d..9c7d9517b 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -428,11 +428,14 @@ function toggleLogFollowLatest(~, textArea, button) installUtilityMenus(obj) - function runUtility(obj, callback) + function runUtility(obj, callback, title) + if nargin < 3 + title = "LabKit Utility"; + end try callback(); catch cause - obj.alert(cause.message, "LabKit Utility"); + obj.alert(cause.message, title); end end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installFilePanelCallbacks.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installFilePanelCallbacks.m index 7062a07f2..6be245aa8 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installFilePanelCallbacks.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installFilePanelCallbacks.m @@ -1,24 +1,30 @@ function installFilePanelCallbacks(obj, node, list) % Class-folder implementation of MatlabPlatformAdapter.installFilePanelCallbacks. handles = list.UserData; - list.ValueChangedFcn = @(src, ~) obj.Runtime.applyFilePanelSelection( ... - node.Id, labkit.app.internal.NativeAdapterValues.selectedIndices(src)); - handles.Choose.ButtonPushedFcn = @(~, ~) obj.chooseFiles(node.Id); + list.ValueChangedFcn = @(src, ~) obj.runUtility(@() ... + obj.Runtime.applyFilePanelSelection(node.Id, ... + labkit.app.internal.NativeAdapterValues.selectedIndices(src)), ... + "Could not select file"); + handles.Choose.ButtonPushedFcn = @(~, ~) obj.runUtility( ... + @() obj.chooseFiles(node.Id), "Could not add files"); if ~isempty(handles.Folder) - handles.Folder.ButtonPushedFcn = @(~, ~) ... - obj.chooseFolderFiles(node.Id, false); + handles.Folder.ButtonPushedFcn = @(~, ~) obj.runUtility( ... + @() obj.chooseFolderFiles(node.Id, false), ... + "Could not add folder"); end if ~isempty(handles.RecursiveFolder) - handles.RecursiveFolder.ButtonPushedFcn = @(~, ~) ... - obj.chooseFolderFiles(node.Id, true); + handles.RecursiveFolder.ButtonPushedFcn = @(~, ~) obj.runUtility( ... + @() obj.chooseFolderFiles(node.Id, true), ... + "Could not add folder tree"); end if ~isempty(handles.Remove) handles.Remove.ButtonPushedFcn = @(~, ~) ... obj.removeSelectedFiles(node.Id, list); end if ~isempty(handles.Clear) - handles.Clear.ButtonPushedFcn = @(~, ~) ... + handles.Clear.ButtonPushedFcn = @(~, ~) obj.runUtility(@() ... obj.Runtime.applyFileSelection( ... - node.Id, strings(1, 0), zeros(1, 0)); + node.Id, strings(1, 0), zeros(1, 0)), ... + "Could not clear files"); end end diff --git a/+labkit/+app/+layout/fileList.m b/+labkit/+app/+layout/fileList.m index 082989fd8..db26188e1 100644 --- a/+labkit/+app/+layout/fileList.m +++ b/+labkit/+app/+layout/fileList.m @@ -15,7 +15,10 @@ % Label - Reader-facing collection label. Default: id. % Mode - "files" or "folder". Default: "files". % Filters - File-dialog filter text row. Default: strings(1,0). -% SelectionMode - "single" or "multiple". Default: "multiple". +% SelectionMode - "single" or "multiple" for both the native file chooser +% and list-row selection. Multi-file collections use "multiple"; a +% single semantic input normally combines "single" with MaxFiles=1. +% Default: "multiple". % MaxFiles - Positive scalar or Inf. Default: Inf. % FolderWarningThreshold - Positive scalar or Inf. Default: 500. % ShowStatus - Logical status visibility. Default: true. @@ -59,6 +62,8 @@ % % Errors: % Throws labkit:app:contract:* for invalid options, paths, or callbacks. +% In a native App, an unhandled file-panel validation or parsing exception +% is rolled back and shown in an alert. % % Typical Call: % node = labkit.app.layout.fileList("files", ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m index 205b89701..8a68bff86 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m @@ -3,11 +3,11 @@ %LAYOUTSECTION Declare source collection and crop-task navigation. files = labkit.app.layout.fileList("images", ... Label="Crop images", Filters=labkit.image.fileDialogFilter(), ... - SelectionMode="single", Bind="project.inputs.sources", ... + SelectionMode="multiple", Bind="project.inputs.sources", ... OnSelectionChanged=@batch_crop.sourceFiles.selectionChanged, ... SourceRole="cropSource", SourceIdPrefix="image", Required=true, ... AllowDuplicatePaths=true, ... - ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseLabel="Add images", FolderLabel="Add folder", ... ChooseTooltip="Add source images as independent crop tasks; duplicate paths remain separate tasks.", ... RecursiveFolderLabel="Add folder tree", ... RemoveLabel="Remove selected", ClearLabel="Clear images", ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/definition.m b/apps/image_measurement/batch_crop/+batch_crop/definition.m index 6ea2d4343..bde83d194 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/definition.m +++ b/apps/image_measurement/batch_crop/+batch_crop/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition(Entrypoint="labkit_BatchImageCrop_app", ... AppId="batch_crop", Title="Microscope Batch Image Crop", ... DisplayName="Batch Image Crop", Family="Image Measurement", ... - AppVersion="1.9.2", Updated="2026-07-30", ... + AppVersion="1.9.3", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=batch_crop.projectSpec(), CreateSession=@batch_crop.createSession, ... Workbench=batch_crop.workbench.buildLayout(), PresentWorkbench=@batch_crop.workbench.present, ... diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m index 11929a747..1f88406c7 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m @@ -4,11 +4,13 @@ files = labkit.app.layout.fileList("thermalFiles", ... Label="FLIR files", ... Filters=labkit.thermal.fileDialogFilter("IncludeAll", true), ... - SelectionMode="single", Bind="project.inputs.sources", ... + SelectionMode="multiple", Bind="project.inputs.sources", ... SelectionBind="session.selection.thermalSources", ... OnSelectionChanged=@flir_thermal.thermalSources.selectCurrent, ... SourceRole="thermal-image", SourceIdPrefix="thermal", Required=true, ... - ChooseLabel="Add FLIR files or folder", ... + PathFilter=@flir_thermal.thermalSources.matchesRadiometricFiles, ... + PathFilterDescription="radiometric FLIR image", ... + ChooseLabel="Add FLIR files", ... ChooseTooltip="Add radiometric FLIR files with embedded calibration metadata for temperature conversion.", ... FolderLabel="Add folder", RecursiveFolderLabel="Add folder tree", ... RemoveLabel="Remove selected", ClearLabel="Clear files", ... diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/matchesRadiometricFiles.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/matchesRadiometricFiles.m new file mode 100644 index 000000000..f68225d07 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/matchesRadiometricFiles.m @@ -0,0 +1,11 @@ +% Expected caller: FLIR Thermal fileList PathFilter. Candidate paths are +% inspected through the thermal facade; unreadable or non-radiometric files +% are rejected before portable source records are created. +function accepted = matchesRadiometricFiles(paths) +paths = reshape(string(paths), 1, []); +accepted = false(size(paths)); +for index = 1:numel(paths) + inspection = labkit.thermal.inspectFile(paths(index)); + accepted(index) = inspection.isThermal; +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m index 6d8732bfe..f4d11ef81 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m @@ -6,7 +6,7 @@ Entrypoint="labkit_FLIRThermal_app", ... AppId="flir_thermal", ... Title="FLIR Thermal Postprocess", DisplayName="FLIR Thermal", ... - Family="Image Measurement", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements( ... "app", ">=2 <3", "image", ">=2.0 <3", ... "thermal", ">=1.1 <2"), ... diff --git a/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m b/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m index 8c998583d..3c774c21d 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m @@ -6,7 +6,7 @@ SelectionMode="multiple", Bind="project.inputs.sources", ... SelectionBind="session.selection.sourceImages", SourceRole="focus-image", ... SourceIdPrefix="image", Required=true, ... - ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseLabel="Add images", FolderLabel="Add folder", ... ChooseTooltip="Add a z-stack of images from the same field of view at different focal planes.", ... RecursiveFolderLabel="Add folder tree", ... RemoveLabel="Remove selected", ClearLabel="Clear images", ... diff --git a/apps/image_measurement/focus_stack/+focus_stack/definition.m b/apps/image_measurement/focus_stack/+focus_stack/definition.m index 19ff52332..6ad6fc080 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/definition.m +++ b/apps/image_measurement/focus_stack/+focus_stack/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_FocusStack_app", AppId="focus_stack", ... Title="Microscope Focus Stack Fusion", DisplayName="Focus Stack", ... - Family="Image Measurement", AppVersion="1.7.1", Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.7.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=focus_stack.projectSpec(), CreateSession=@focus_stack.createSession, ... Workbench=focus_stack.workbench.buildLayout(), ... diff --git a/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m b/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m index 080f7b925..2b188ef44 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m @@ -9,7 +9,7 @@ SelectionBind="session.selection.sourceImages", ... OnSelectionChanged=@image_enhance.sourceLibrary.selectPreview, ... SourceRole="source-image", SourceIdPrefix="image", ... - ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseLabel="Add images", FolderLabel="Add folder", ... ChooseTooltip="Add source images whose pixel values will be processed through a reproducible enhancement history.", ... RecursiveFolderLabel="Add folder tree", ... RemoveLabel="Remove selected", ClearLabel="Clear images", ... diff --git a/apps/image_measurement/image_enhance/+image_enhance/definition.m b/apps/image_measurement/image_enhance/+image_enhance/definition.m index c431f811f..01e5b21af 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/definition.m +++ b/apps/image_measurement/image_enhance/+image_enhance/definition.m @@ -4,7 +4,7 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_ImageEnhance_app", ... AppId="image_enhance", Title="Paper Image Enhance", DisplayName="Image Enhance", ... - Family="Image Measurement", AppVersion="1.8.1", Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.8.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=image_enhance.projectSpec(), CreateSession=@image_enhance.createSession, ... Workbench=image_enhance.workbench.buildLayout(), PresentWorkbench=@image_enhance.workbench.present, ... diff --git a/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m b/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m index 957e5e15e..5d86029d3 100644 --- a/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m +++ b/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m @@ -19,7 +19,7 @@ SelectionBind="session.selection.sourceImages", ... OnSelectionChanged=@image_match.sourceFiles.selectPreview, ... SourceRole="source-image", SourceIdPrefix="image", ... - ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseLabel="Add images", FolderLabel="Add folder", ... ChooseTooltip="Add source images that will be matched to the selected reference without modifying the originals.", ... RecursiveFolderLabel="Add folder tree", ... RemoveLabel="Remove selected", ClearLabel="Clear images", ... diff --git a/apps/image_measurement/image_match/+image_match/definition.m b/apps/image_measurement/image_match/+image_match/definition.m index 9c001b27f..d4f241c48 100644 --- a/apps/image_measurement/image_match/+image_match/definition.m +++ b/apps/image_measurement/image_match/+image_match/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_ImageMatch_app", AppId="image_match", ... Title="Paper Image Match", DisplayName="Image Match", ... - Family="Image Measurement", AppVersion="1.8.1", Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.8.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=image_match.projectSpec(), CreateSession=@image_match.createSession, ... Workbench=image_match.workbench.buildLayout(), ... diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m index 90d9fb129..9e09c299c 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m @@ -3,8 +3,8 @@ section = labkit.app.layout.section("sourceSection", "MATLAB Figures", { ... labkit.app.layout.fileList("figFiles", Label="FIG files", ... Filters=["*.fig", "MATLAB figure (*.fig)"], ... - SelectionMode="single", ShowStatus=false, ... - ChooseLabel="Add FIG files or scan folder", ... + SelectionMode="multiple", ShowStatus=false, ... + ChooseLabel="Add FIG files", ... ChooseTooltip="Add MATLAB FIG files whose axes data and styling will be inspected without rerunning the source analysis.", ... FolderLabel="Add folder", ... RecursiveFolderLabel="Add folder tree", ... diff --git a/apps/labkit_core/figure_studio/+figure_studio/definition.m b/apps/labkit_core/figure_studio/+figure_studio/definition.m index 202071520..79bc77c4c 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/definition.m +++ b/apps/labkit_core/figure_studio/+figure_studio/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_FigureStudio_app", AppId="figure_studio", ... Title="Figure Studio", Family="LabKit Core", ... - AppVersion="0.7.2", Updated="2026-07-30", ... + AppVersion="0.7.3", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=figure_studio.projectSpec(), ... CreateSession=@figure_studio.createSession, ... diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m index c5b12a6d7..a56c79d8d 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m @@ -5,7 +5,7 @@ Label="RHS filter files", ... Filters=["*.rhs", "Intan RHS files"], ... SelectionMode="multiple", ... - ChooseLabel="Add RHS files or folder", ... + ChooseLabel="Add RHS files", ... ChooseTooltip="Add Intan RHS recordings to evaluate against the current reusable file-filter rules.", ... FolderLabel="Add folder", ... RecursiveFolderLabel="Add folder tree", ... diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m index 104fb60ee..729e5f2bc 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m @@ -4,7 +4,7 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_RHSPreview_app", ... AppId="rhs_preview", Title="RHS Preview", DisplayName="RHS Preview", ... - Family="Neurophysiology", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Neurophysiology", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "rhs", ">=1.0 <2"), ... ProjectSchema=rhs_preview.projectSpec(), CreateSession=@rhs_preview.createSession, ... Workbench=rhs_preview.workbench.buildLayout(), PresentWorkbench=@rhs_preview.workbench.present, ... diff --git a/docs/apps/image-measurement/flir-thermal/README.md b/docs/apps/image-measurement/flir-thermal/README.md index ac8f97e08..f09193b68 100644 --- a/docs/apps/image-measurement/flir-thermal/README.md +++ b/docs/apps/image-measurement/flir-thermal/README.md @@ -15,10 +15,12 @@ labkit_FLIRThermal_app ## Inputs And Navigation -Use **Add FLIR files or folder** to load one or more radiometric images. The -selected row is the current image; Previous/Next change selection without -discarding per-image display range or measurement annotations. Source files -are read-only. +Use **Add FLIR files** to select one or more radiometric images. Use the +separate folder buttons for one folder or a recursive folder tree. Candidates +that are not readable radiometric FLIR images are omitted with an aggregate +notice. The selected row is the current image; Previous/Next change selection +without discarding per-image display range or measurement annotations. Source +files are read-only. ## Basic Workflow diff --git a/docs/framework/README.md b/docs/framework/README.md index cb382e9cd..7d6f48273 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -77,6 +77,9 @@ and renderer signatures, and builds one private native platform plan. - Bind ordinary state with `Bind="project..."` or `Bind="session..."`. - Use `labkit.app.layout.fileList` for portable file records and selection. + Multi-file collections use native multi-selection; a semantic single-file + slot declares `MaxFiles=1` and single selection. File buttons describe only + files because folder and recursive-tree acquisition have separate controls. Set `AllowDuplicatePaths=true` only when separate workflow tasks may share one resolved path, and present row-level workflow state with `Snapshot.fileItemStatuses`. @@ -85,6 +88,8 @@ and renderer signatures, and builds one private native platform plan. `PathFilterDescription`. The runtime applies the predicate only to newly proposed files, omits rejected paths before source records are created, and reports aggregate kept/filtered counts without exposing filenames. + Unhandled validation or parsing failures from file-panel actions roll back + transactionally and appear in an alert rather than only in callback output. Source changes rebuild the transient session; Apps do not mirror choose, remove, clear, or selection UI events. - Give every scientific or workflow action an App-owned `Tooltip`. The diff --git a/docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md b/docs/history/records/2026/08/LK-20260803-file-selection-validation.md similarity index 57% rename from docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md rename to docs/history/records/2026/08/LK-20260803-file-selection-validation.md index 26e92c283..a97c2361a 100644 --- a/docs/history/records/2026/08/LK-20260803-electrochem-batch-source-filtering.md +++ b/docs/history/records/2026/08/LK-20260803-file-selection-validation.md @@ -1,7 +1,7 @@ -# Electrochemistry batch imports retain compatible DTA files +# File collections support consistent selection and validation ```labkit-change -id: LK-20260803-electrochem-batch-source-filtering +id: LK-20260803-file-selection-validation date: 2026-08-03 sequence: 170 type: fix @@ -12,9 +12,18 @@ component: `labkit_CIC_app` | `1.6.1 -> 1.6.2` component: `labkit_CSC_app` | `1.6.1 -> 1.6.2` component: `labkit_EIS_app` | `1.6.1 -> 1.6.2` component: `labkit_VTResistance_app` | `1.6.1 -> 1.6.2` +component: `labkit_BatchCrop_app` | `1.9.2 -> 1.9.3` +component: `labkit_FLIRThermal_app` | `1.6.1 -> 1.6.2` +component: `labkit_FocusStack_app` | `1.7.1 -> 1.7.2` +component: `labkit_ImageEnhance_app` | `1.8.1 -> 1.8.2` +component: `labkit_ImageMatch_app` | `1.8.1 -> 1.8.2` +component: `labkit_FigureStudio_app` | `0.7.2 -> 0.7.3` +component: `labkit_RHSPreview_app` | `1.6.1 -> 1.6.2` scope: Electrochemistry multi-file import scope: Folder and recursive DTA filtering scope: App SDK file-list path predicates +scope: Cross-App file chooser consistency +scope: FLIR radiometric candidate validation ``` ## Context @@ -24,6 +33,10 @@ selection even though their workflows and exports support batches. Folder and recursive-folder actions selected every `.DTA` path by extension, so a folder containing another Gamry experiment type caused session reconstruction to fail transactionally and discarded compatible files in the same batch. +FLIR Thermal, Batch Crop, and Figure Studio also represented file collections +with single-selection controls, while several file buttons still duplicated +legacy folder wording despite separate folder actions. File-panel parsing +exceptions had no framework-owned alert fallback. ## Decision and rationale @@ -32,6 +45,11 @@ path predicate and a standard aggregate filtering notice. Keep experiment type detection in each electrochemistry App through the DTA facade: the SDK owns selection lifecycle and interaction consistency, while Apps retain the scientific meaning of chrono, CV/CT, and EIS inputs. +Apply the same acquisition contract across every public App: collections are +multi-select, semantic one-file slots remain explicitly bounded, and file +buttons do not claim folder behavior. Keep radiometric acceptance in FLIR +Thermal through the thermal facade, and let the private native adapter surface +otherwise-unhandled file-action failures without changing App-owned wording. ## Changes @@ -44,6 +62,13 @@ scientific meaning of chrono, CV/CT, and EIS inputs. filtered. - Enabled native multi-file selection for CIC, CSC, and VT Resistance. - Declared chrono, CV/CT, or EIS predicates for all five electrochemistry Apps. +- Enabled multi-file selection for FLIR Thermal, Batch Crop, and Figure Studio. +- Removed legacy folder wording from file buttons across affected image and + neurophysiology Apps while preserving the separate folder/tree controls. +- Added FLIR content-level candidate inspection so ordinary JPEGs, unreadable + payloads, and wrong file types are omitted with the standard aggregate alert. +- Added a native file-panel error fallback so an unhandled parsing or + validation failure is presented in an alert after transactional rollback. ## User and data impact @@ -52,6 +77,9 @@ when it contains other DTA experiment types. Matching files keep their order and portable identities; unsupported paths are not stored. The notice reports counts only and does not expose source filenames or paths. Source files and saved project schemas are unchanged. +Single-file roles such as a DIC image, protocol JSON, video, or source table +remain intentionally limited to one file. No scientific calculation, source +payload, or result schema changes. ## Compatibility and migration @@ -69,19 +97,25 @@ chrono, CV/CT, and EIS discrimination. One existing hidden-GUI workflow per electrochemistry App covers the mixed batch through plotting, analysis, export, and project restore; CIC, CSC, and VT Resistance also verify native multiple selection. +The public-App conformance specification checks every compiled file collection +for multi-selection and unambiguous file-button wording. FLIR source evidence +covers readable radiometric data, ordinary JPEGs, and wrong extensions. A +hidden native SDK specification verifies that an unhandled source parsing +failure produces an error alert. ## Evidence - App SDK plus five App source specification files: 29 identities passed. - Five electrochemistry hidden-GUI workflow specification files: 5 identities passed. -- Authored-link validation checked 252 Markdown sources with no unresolved - links; deterministic documentation generation compared 382 files across - two independent renders. +- Cross-App file-entry and SDK/FLIR focused specifications: 64 identities + passed across 21 public Apps. ## Known limitations and follow-up Automated tests do not operate native file and folder dialogs or prove behavior on approved laboratory data. The predicates use the supported DTA content detector; a malformed file that cannot be classified is intentionally -reported as filtered rather than registered as an analysis source. +reported as filtered rather than registered as an analysis source. Hidden GUI +tests do not operate native file or folder dialogs, so manual dialog feel and +platform-specific chooser rendering remain outside automation. diff --git a/tests/specs/apps/conformance/AppDefinitionConformanceSpec.m b/tests/specs/apps/conformance/AppDefinitionConformanceSpec.m index a69becea3..329bfd5fc 100644 --- a/tests/specs/apps/conformance/AppDefinitionConformanceSpec.m +++ b/tests/specs/apps/conformance/AppDefinitionConformanceSpec.m @@ -16,5 +16,25 @@ function declaresThePublicAppContract(testCase, App) testCase.verifyTrue(labkit.contract.checkRequirements( ... definition.Requirements).ok); end + + function declaresUnambiguousFileCollectionControls(testCase, App) + definition = feval(char(App.Package + ".definition")); + plan = labkit.app.internal.DefinitionInspector.platformPlan( ... + definition); + nodes = plan.Nodes(string({plan.Nodes.Kind}) == "fileList"); + + for index = 1:numel(nodes) + config = nodes(index).Configuration; + if config.MaxFiles ~= 1 + testCase.verifyEqual(config.SelectionMode, "multiple", ... + "Multi-file collection must support file multi-selection: " + ... + App.Package + "." + nodes(index).Id); + end + testCase.verifyFalse(contains(lower(config.ChooseLabel), ... + ["folder", "directory"]), ... + "The file button must not duplicate the separate folder controls: " + ... + App.Package + "." + nodes(index).Id); + end + end end end diff --git a/tests/specs/apps/image_measurement/flir_thermal/thermalSources/FlirThermalSelectionSpec.m b/tests/specs/apps/image_measurement/flir_thermal/thermalSources/FlirThermalSelectionSpec.m new file mode 100644 index 000000000..7cc6002b3 --- /dev/null +++ b/tests/specs/apps/image_measurement/flir_thermal/thermalSources/FlirThermalSelectionSpec.m @@ -0,0 +1,39 @@ +classdef FlirThermalSelectionSpec < matlab.unittest.TestCase + %FLIRTHERMALSELECTIONSPEC Specify FLIR source acquisition behavior. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function declaresABatchFileChooserWithRadiometricFiltering(testCase) + plan = labkit.app.internal.DefinitionInspector.platformPlan( ... + flir_thermal.definition()); + node = plan.Nodes(string({plan.Nodes.Id}) == "thermalFiles"); + config = node.Configuration; + + testCase.verifyEqual(config.SelectionMode, "multiple"); + testCase.verifyEqual(config.ChooseLabel, "Add FLIR files"); + testCase.verifyEqual(config.PathFilterDescription, ... + "radiometric FLIR image"); + testCase.verifyNotEmpty(config.PathFilter); + end + + function filtersUnreadableCandidatesBeforeRegisteringSources(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + thermalPath = fullfile(folder, "synthetic_flir.jpg"); + ordinaryPath = fullfile(folder, "ordinary.jpg"); + wrongTypePath = fullfile(folder, "notes.txt"); + testfixtures.writeSyntheticFlirRjpegFixture(thermalPath); + imwrite(uint8(120 .* ones(5, 6, 3)), ordinaryPath); + file = fopen(wrongTypePath, "w"); + cleanup = onCleanup(@() fclose(file)); + fprintf(file, "not thermal"); + clear cleanup + + accepted = ... + flir_thermal.thermalSources.matchesRadiometricFiles( ... + [string(thermalPath), string(ordinaryPath), ... + string(wrongTypePath)]); + + testCase.verifyEqual(accepted, [true false false]); + end + end +end diff --git a/tests/specs/labkit/app/AppSdkSpec.m b/tests/specs/labkit/app/AppSdkSpec.m index 30372b42b..24ac96702 100644 --- a/tests/specs/labkit/app/AppSdkSpec.m +++ b/tests/specs/labkit/app/AppSdkSpec.m @@ -474,6 +474,36 @@ function namesScreenshotTargetsAndSavesProjectStateToArtifacts(testCase) testCase.verifyEqual(notice.icon, "info"); clear fileCleanup cleanup end + + function filePanelFailuresAlwaysShowAnAlert(testCase) + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.fileList("files", ... + Bind="project.inputs.sources", ... + SelectionMode="single", MaxFiles=1, ... + OnSelectionChanged=@failSourceSelection)}); + app = AppSdkSpec.definition(layout, "ProjectSchema", ... + labkit.app.project.Schema( ... + Version=1, Create=@createUnreadableSourceProject, ... + Validate=@validateSourceProject)); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + figureValue = runtime.figureHandle(); + list = oneTagged(figureValue, "files"); + + list.ValueChangedFcn(list, []); + drawnow; + + notice = getappdata(figureValue, "labkitAppLastAlert"); + testCase.verifyEqual(notice.title, "Could not select file"); + testCase.verifyEqual(notice.icon, "error"); + testCase.verifySubstring(notice.message, ... + "Synthetic source parse failure"); + clear cleanup + end end methods (Static, Access = private) @@ -620,6 +650,16 @@ function captureAlert(store, message, title) project = struct("parameters", struct("gain", 1, "unit", "base")); end +function project = createUnreadableSourceProject() +project = createSourceProject(); +project.inputs.sources = labkit.app.project.sourceRecord( ... + "source1", "files", "unreadable.dat", true); +end + +function applicationState = failSourceSelection(applicationState, ~, ~) +error("probe:UnreadableSource", "Synthetic source parse failure."); +end + function accepted = validateCurrentProject(project) accepted = isstruct(project) && isscalar(project) && ... isfield(project, "parameters") && ... From f790bd6b72f8f6fc8f50aaa081e0ccb336ab8488 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 15:05:32 -0500 Subject: [PATCH 10/32] feat: retain complete session diagnostics --- +labkit/+app/+internal/RuntimeKernel.m | 44 +++---- .../+app/+internal/SessionDiagnosticBundle.m | 109 +++++++++++++----- +labkit/+app/+internal/SessionDiagnostics.m | 15 ++- +labkit/+app/+internal/SessionEventStream.m | 20 ++-- .../+app/+internal/SessionEventValidator.m | 103 ++++++++++++++++- +labkit/+app/+internal/SessionJournal.m | 2 +- .../+app/+internal/SessionJournalArchive.m | 8 +- +labkit/+app/+internal/SessionLogViewer.m | 6 +- +labkit/+app/CallbackContext.m | 4 +- docs/framework/README.md | 5 +- docs/getting-started/README.md | 5 +- ...pp-sdk-diagnostics-and-input-workflows.md} | 57 ++++++--- ...process-interaction-registration-repair.md | 3 +- .../LK-20260803-file-selection-validation.md | 39 ++----- .../labkit/app/SessionDiagnosticBundleSpec.m | 47 ++++++-- .../specs/labkit/app/SessionEventStreamSpec.m | 109 +++++++----------- tests/specs/labkit/app/SessionJournalSpec.m | 2 +- tests/specs/labkit/app/SessionLogViewerSpec.m | 2 + .../app/SessionLoggingPrivacyContractSpec.m | 37 +++--- .../labkit/app/SessionLoggingRuntimeSpec.m | 3 +- 20 files changed, 403 insertions(+), 217 deletions(-) rename docs/history/records/2026/08/{LK-20260803-session-log-levels-and-automatic-export.md => LK-20260803-app-sdk-diagnostics-and-input-workflows.md} (55%) diff --git a/+labkit/+app/+internal/RuntimeKernel.m b/+labkit/+app/+internal/RuntimeKernel.m index 528db47e6..baed3e0bd 100644 --- a/+labkit/+app/+internal/RuntimeKernel.m +++ b/+labkit/+app/+internal/RuntimeKernel.m @@ -185,29 +185,30 @@ function setTraceCapture(obj, enabled) end function destination = exportDiagnosticBundle( ... - obj, destination, includePrivateState) + obj, destination, includeSensitiveDetails) if nargin < 3 - includePrivateState = false; + includeSensitiveDetails = false; end - includePrivateState = logicalScalar( ... - includePrivateState, "includePrivateState"); + includeSensitiveDetails = logicalScalar( ... + includeSensitiveDetails, "includeSensitiveDetails"); operation = obj.Recorder.begin( ... "runtime.lifecycle", "diagnostics.bundle_exported", ... "Exporting diagnostic bundle."); try privateState = []; - if includePrivateState + if includeSensitiveDetails privateState = obj.State; end destination = obj.Recorder.exportBundle( ... - destination, operation.Id, privateState); + destination, operation.Id, ... + includeSensitiveDetails, privateState); obj.Recorder.finish( ... operation, "completed", "notApplicable", []); catch cause obj.Recorder.finish( ... operation, "failed", "notApplicable", cause); destination = obj.exportDiagnosticTextFallback( ... - destination, cause); + destination, cause, includeSensitiveDetails); end end @@ -223,14 +224,14 @@ function setTraceCapture(obj, enabled) destination = ""; return; end - includePrivateState = ... + includeSensitiveDetails = ... selection.Value == "Complete log (sensitive)"; destination = ""; try automaticDestination = ... - obj.automaticDiagnosticDestination(includePrivateState); + obj.automaticDiagnosticDestination(includeSensitiveDetails); destination = obj.exportDiagnosticBundle( ... - automaticDestination, includePrivateState); + automaticDestination, includeSensitiveDetails); if endsWith(destination, ".txt", ... IgnoreCase=true) obj.alertDiagnosticTextFallback(destination); @@ -243,7 +244,7 @@ function setTraceCapture(obj, enabled) return; catch automaticFailure fallbackName = diagnosticFallbackName( ... - obj.automaticDiagnosticFilename(includePrivateState)); + obj.automaticDiagnosticFilename(includeSensitiveDetails)); end choice = obj.Context.chooseOutputFile( ... {"*.txt", "Diagnostic text fallback (*.txt)"}, ... @@ -252,19 +253,22 @@ function setTraceCapture(obj, enabled) return; end destination = obj.exportDiagnosticTextFallback( ... - choice.Value, automaticFailure); + choice.Value, automaticFailure, includeSensitiveDetails); obj.alertDiagnosticTextFallback(destination); end function destination = exportDiagnosticTextFallback( ... - obj, preferredDestination, cause) + obj, preferredDestination, cause, includeSensitiveDetails) + if nargin < 4 + includeSensitiveDetails = false; + end obj.Recorder.log( ... "warning", "diagnostics.text_fallback.started", ... "Diagnostic ZIP export failed; writing a plain-text fallback.", ... Category="runtime.lifecycle", Audience="user", ... Exception=cause); destination = obj.Recorder.exportTextFallback( ... - preferredDestination, cause); + preferredDestination, cause, includeSensitiveDetails); end function alertDiagnosticTextFallback(obj, destination) @@ -1151,16 +1155,16 @@ function markDocumentChanged(obj) end function destination = automaticDiagnosticDestination( ... - obj, includePrivateState) - stem = diagnosticArtifactStem(includePrivateState); + obj, includeSensitiveDetails) + stem = diagnosticArtifactStem(includeSensitiveDetails); destination = obj.automaticArtifactDestination( ... "diagnostics", stem, ".zip"); end function filename = automaticDiagnosticFilename( ... - obj, includePrivateState) + obj, includeSensitiveDetails) filename = obj.automaticArtifactFilename( ... - diagnosticArtifactStem(includePrivateState), ".zip"); + diagnosticArtifactStem(includeSensitiveDetails), ".zip"); end function notifyUser(obj, message, title) @@ -1241,8 +1245,8 @@ function assertProjectStore(obj) end end -function stem = diagnosticArtifactStem(includePrivateState) -if includePrivateState +function stem = diagnosticArtifactStem(includeSensitiveDetails) +if includeSensitiveDetails stem = "diagnostics-sensitive"; else stem = "diagnostics"; diff --git a/+labkit/+app/+internal/SessionDiagnosticBundle.m b/+labkit/+app/+internal/SessionDiagnosticBundle.m index e57ed6bbb..8f6bc2dc5 100644 --- a/+labkit/+app/+internal/SessionDiagnosticBundle.m +++ b/+labkit/+app/+internal/SessionDiagnosticBundle.m @@ -1,16 +1,25 @@ classdef (Hidden, Sealed) SessionDiagnosticBundle %SESSIONDIAGNOSTICBUNDLE Write one diagnostic ZIP snapshot. - % SessionDiagnostics supplies canonical privacy-safe events and manifest - % metadata. Runtime may additionally supply one explicitly user-authorized - % App state value; safe export remains the default. + % SessionDiagnostics supplies full retained events and manifest metadata. + % Redaction is applied only after the user selects a redacted export. methods (Static) - function destination = write(snapshot, destination, privateState) + function destination = write(snapshot, destination, ... + includeSensitiveDetails, privateState) if nargin < 3 + includeSensitiveDetails = false; + end + if nargin < 4 privateState = []; end snapshot = validateSnapshot(snapshot); - includesPrivateState = ~isempty(privateState); + includeSensitiveDetails = logical(includeSensitiveDetails); + if ~includeSensitiveDetails + snapshot.events = ... + labkit.app.internal.SessionEventValidator.redactedRecords( ... + snapshot.events); + end + includesPrivateState = includeSensitiveDetails && ~isempty(privateState); if includesPrivateState && ... (~isstruct(privateState) || ~isscalar(privateState)) error("labkit:app:runtime:InvariantFailure", ... @@ -30,7 +39,7 @@ mkdir(char(staging)); cleanup = onCleanup(@() removeStaging(staging)); writeText(fullfile(staging, "README.txt"), ... - readmeLines(snapshot, includesPrivateState)); + readmeLines(snapshot, includeSensitiveDetails, includesPrivateState)); writeJson(fullfile(staging, "manifest.json"), ... snapshot.manifest); writeEvents(fullfile(staging, "events.jsonl"), ... @@ -40,7 +49,7 @@ writeJson(fullfile(staging, "errors.json"), ... errorRecords(snapshot.events)); writeJson(fullfile(staging, "redaction-report.json"), ... - redactionReport(snapshot, includesPrivateState)); + redactionReport(snapshot, includeSensitiveDetails, includesPrivateState)); if includesPrivateState writePrivateState(fullfile(staging, "app-state.mat"), ... privateState); @@ -69,10 +78,18 @@ clear zipCleanup cleanup end - function destination = writeFallback(snapshot, preferredDestination) - % RuntimeKernel supplies sanitized in-memory records and an - % automatic or explicitly selected destination. + function destination = writeFallback( ... + snapshot, preferredDestination, includeSensitiveDetails) + if nargin < 3 + includeSensitiveDetails = false; + end snapshot = validateFallbackSnapshot(snapshot); + includeSensitiveDetails = logical(includeSensitiveDetails); + if ~includeSensitiveDetails + snapshot.events = ... + labkit.app.internal.SessionEventValidator.redactedRecords( ... + snapshot.events); + end destination = fallbackPath(preferredDestination); folder = string(fileparts(destination)); if strlength(folder) == 0 @@ -83,7 +100,8 @@ error("labkit:app:runtime:DiagnosticWriteFailed", ... "The diagnostic text fallback folder is unavailable."); end - writeText(destination, fallbackLines(snapshot)); + writeText(destination, fallbackLines( ... + snapshot, includeSensitiveDetails)); end end end @@ -129,10 +147,12 @@ function validateRecord(record) error("labkit:app:runtime:InvariantFailure", ... "Diagnostic bundle record is not canonical."); end -labkit.app.internal.SessionEventValidator.privacySafeText( ... - record.message, "message"); -labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... - record.attributes); +try + jsonencode(record); +catch + error("labkit:app:runtime:InvariantFailure", ... + "Diagnostic bundle record is not serializable."); +end end function snapshot = validateFallbackSnapshot(snapshot) @@ -176,13 +196,14 @@ function validateRecord(record) end end -function value = readmeLines(snapshot, includesPrivateState) +function value = readmeLines( ... + snapshot, includeSensitiveDetails, includesPrivateState) capture = snapshot.capture; degradation = snapshot.degradation; value = [ "LabKit Diagnostic Bundle" "" - privacyDescription(includesPrivateState) + privacyDescription(includeSensitiveDetails, includesPrivateState) "" "Capture notes:" "- TRACE enabled at export: " + yesNo(capture.traceEnabled) @@ -201,13 +222,17 @@ function validateRecord(record) ]; end -function value = privacyDescription(includesPrivateState) -if includesPrivateState +function value = privacyDescription( ... + includeSensitiveDetails, includesPrivateState) +if includeSensitiveDetails value = [ ... "Sensitive export was explicitly enabled by the user." - "app-state.mat contains the current App project and session state and may include paths, filenames, scientific values, results, and decoded images." + "Session events contain the complete retained messages, attributes, exception messages, and stack locations and may include paths, filenames, and scientific values." "External source files and screenshots are not copied into this bundle." ]; + if includesPrivateState + value(end + 1, 1) = "app-state.mat contains the current App project and session state and may include paths, filenames, scientific values, results, and decoded images."; + end else value = [ ... "This bundle contains privacy-safe Runtime session records only." @@ -216,14 +241,13 @@ function validateRecord(record) end end -function value = fallbackLines(snapshot) +function value = fallbackLines(snapshot, includeSensitiveDetails) application = snapshot.application; capture = snapshot.capture; value = [ "LabKit Diagnostic Text Fallback" "" - "The normal diagnostic ZIP could not be written. This single text file contains the surviving privacy-safe Runtime session records." - "It does not contain projects, scientific inputs or results, images, screenshots, source files, paths, or original filenames." + fallbackPrivacyLines(includeSensitiveDetails) "" "Application:" "- Name: " + textField(application, "title") @@ -244,11 +268,39 @@ function validateRecord(record) "Session timeline:" timeline(snapshot.events) "" + "Structured session records:" + fallbackEventLines(snapshot.events) + "" "Structured failure records:" fallbackErrorLines(snapshot.events) ]; end +function value = fallbackPrivacyLines(includeSensitiveDetails) +if includeSensitiveDetails + value = [ ... + "The normal diagnostic ZIP could not be written. This fallback preserves the selected complete-log mode." + "It contains full retained messages, attributes, exception messages, and stack locations and may contain sensitive paths, filenames, and scientific values." + ]; +else + value = [ ... + "The normal diagnostic ZIP could not be written. This fallback preserves the selected redacted-log mode." + "It excludes projects, scientific inputs or results, images, screenshots, source files, paths, and original filenames." + ]; +end +end + +function value = fallbackEventLines(events) +if isempty(events) + value = "(none)"; + return; +end +value = strings(numel(events), 1); +for index = 1:numel(events) + value(index) = string(jsonencode(events(index))); +end +end + function value = fallbackErrorLines(events) records = errorRecords(events); if isempty(records) @@ -332,12 +384,13 @@ function validateRecord(record) "rootActionId", "", "exception", struct()); end -function value = redactionReport(snapshot, includesPrivateState) -if includesPrivateState - projection = "canonical-safe-events-plus-opt-in-app-state"; +function value = redactionReport( ... + snapshot, includeSensitiveDetails, includesPrivateState) +if includeSensitiveDetails + projection = "complete-retained-events-plus-opt-in-app-state"; excluded = ["screenshots"; "source-files"]; else - projection = "canonical-safe-events-only"; + projection = "redacted-at-export"; excluded = [ ... "paths" "filenames" @@ -352,7 +405,7 @@ function validateRecord(record) end value = struct( ... "schemaVersion", 1, ... - "privacyBoundary", "validated-before-retention", ... + "privacyBoundary", "redacted-only-after-export-selection", ... "exportProjection", projection, ... "includesPrivateAppState", includesPrivateState, ... "excludedData", excluded, ... diff --git a/+labkit/+app/+internal/SessionDiagnostics.m b/+labkit/+app/+internal/SessionDiagnostics.m index d8f376ec0..569bb3d5b 100644 --- a/+labkit/+app/+internal/SessionDiagnostics.m +++ b/+labkit/+app/+internal/SessionDiagnostics.m @@ -101,11 +101,15 @@ function setTraceEnabled(obj, enabled) end function destination = exportBundle( ... - obj, destination, excludeOperationId, privateState) + obj, destination, excludeOperationId, ... + includeSensitiveDetails, privateState) if nargin < 3 excludeOperationId = ""; end if nargin < 4 + includeSensitiveDetails = false; + end + if nargin < 5 privateState = []; end obj.Journal.flush(); @@ -144,11 +148,14 @@ function setTraceEnabled(obj, enabled) "degradation", degradation, "capture", capture); destination = ... labkit.app.internal.SessionDiagnosticBundle.write( ... - snapshot, destination, privateState); + snapshot, destination, includeSensitiveDetails, privateState); end function destination = exportTextFallback( ... - obj, preferredDestination, failure) + obj, preferredDestination, failure, includeSensitiveDetails) + if nargin < 4 + includeSensitiveDetails = false; + end % Keep this path independent of the journal and ZIP staging so a % failure in either subsystem cannot consume the last evidence. try @@ -182,7 +189,7 @@ function setTraceEnabled(obj, enabled) "failureIdentifier", failureIdentifier); destination = ... labkit.app.internal.SessionDiagnosticBundle.writeFallback( ... - snapshot, preferredDestination); + snapshot, preferredDestination, includeSensitiveDetails); end function close(obj) diff --git a/+labkit/+app/+internal/SessionEventStream.m b/+labkit/+app/+internal/SessionEventStream.m index 1419ff5f0..c27247830 100644 --- a/+labkit/+app/+internal/SessionEventStream.m +++ b/+labkit/+app/+internal/SessionEventStream.m @@ -1,5 +1,5 @@ classdef (Hidden, Sealed) SessionEventStream < handle - %SESSIONEVENTSTREAM Private privacy-safe in-memory session event stream. + %SESSIONEVENTSTREAM Private full-detail in-memory session event stream. % Expected callers are the private App Runtime and focused framework tests. % Records are validated before entering the bounded ring; persistence and % viewer projections intentionally belong to later migration checkpoints. @@ -72,10 +72,11 @@ category, "category"); eventName = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... eventName, "eventName"); - message = labkit.app.internal.SessionEventValidator.privacySafeText( ... - message, "message"); - attributes = labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... - optionValue(varargin, "Attributes", struct())); + values = labkit.app.internal.SessionEventValidator.logInputs( ... + "debug", eventName, message, category, "developer", ... + optionValue(varargin, "Attributes", struct()), []); + message = values.message; + attributes = values.attributes; obj.OperationSequence = obj.OperationSequence + 1; parent = obj.currentOperation(); operation = struct( ... @@ -530,6 +531,11 @@ function notifyConsumers(obj, record) "Session event Exception must be a scalar MException."); end exception.identifier = string(value.identifier); -exception.message = "Exception captured."; -exception.stack = string({value.stack.name}).'; +exception.message = string(value.message); +stack = value.stack; +exception.stack = strings(numel(stack), 1); +for index = 1:numel(stack) + exception.stack(index) = string(stack(index).name) + " (" + ... + string(stack(index).file) + ":" + string(stack(index).line) + ")"; +end end diff --git a/+labkit/+app/+internal/SessionEventValidator.m b/+labkit/+app/+internal/SessionEventValidator.m index cdfe36772..e6134290c 100644 --- a/+labkit/+app/+internal/SessionEventValidator.m +++ b/+labkit/+app/+internal/SessionEventValidator.m @@ -1,5 +1,5 @@ classdef (Hidden, Sealed) SessionEventValidator - %SESSIONEVENTVALIDATOR Validate privacy-safe private session event inputs. + %SESSIONEVENTVALIDATOR Validate and project private session event inputs. methods (Static) function values = logInputs(severity, eventName, message, ... @@ -8,16 +8,25 @@ "severity", labkit.app.internal.SessionEventValidator.severity(severity), ... "eventName", labkit.app.internal.SessionEventValidator.semanticIdentifier( ... eventName, "eventName"), ... - "message", labkit.app.internal.SessionEventValidator.privacySafeText( ... - message, "message"), ... + "message", diagnosticText(message, "message"), ... "category", labkit.app.internal.SessionEventValidator.semanticIdentifier( ... category, "category"), ... "audience", labkit.app.internal.SessionEventValidator.audience(audience), ... - "attributes", labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... - attributes), ... + "attributes", diagnosticAttributes(attributes), ... "exception", labkit.app.internal.SessionEventValidator.exception(exception)); end + function records = redactedRecords(records) + for index = 1:numel(records) + records(index).message = retainedTextProjection( ... + records(index).message); + records(index).attributes = retainedAttributesProjection( ... + records(index).attributes); + records(index).exception = redactedExceptionProjection( ... + records(index).exception); + end + end + function value = semanticIdentifier(value, name) if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... strlength(strip(string(value))) == 0 @@ -168,6 +177,90 @@ end end +function value = diagnosticText(value, name) +if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... + ismissing(string(value)) + error("labkit:app:contract:InvalidValue", ... + "Session event %s must be scalar text.", name); +end +value = string(value); +if strlength(value) > 65536 + error("labkit:app:contract:InvalidValue", ... + "Session event %s exceeds the live diagnostic-text limit.", name); +end +end + +function attributes = diagnosticAttributes(attributes) +if ~isstruct(attributes) || ~isscalar(attributes) + error("labkit:app:contract:InvalidValue", ... + "Session event attributes must be one scalar struct."); +end +if numel(fieldnames(attributes)) > 64 + error("labkit:app:contract:InvalidValue", ... + "Session event attributes exceed the live diagnostic field limit."); +end +try + encoded = jsonencode(attributes); +catch + error("labkit:app:contract:InvalidValue", ... + "Session event attributes must be JSON serializable."); +end +if utf8ByteCount(encoded) > 262144 + error("labkit:app:contract:InvalidValue", ... + "Session event attributes exceed the diagnostic JSON byte limit."); +end +end + +function value = retainedTextProjection(value) +try + value = labkit.app.internal.SessionEventValidator.privacySafeText( ... + value, "message"); +catch + value = "Sensitive diagnostic detail was removed from this redacted export."; +end +end + +function attributes = retainedAttributesProjection(attributes) +try + attributes = ... + labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... + attributes); +catch + attributes = struct("reason", "sensitive-detail-redacted"); +end +end + +function exception = redactedExceptionProjection(exception) +if ~isstruct(exception) || ~isscalar(exception) || ... + ~all(isfield(exception, ["identifier", "message", "stack"])) + exception = struct("identifier", "", "message", "", ... + "stack", strings(0, 1)); + return; +end +exception.identifier = string(exception.identifier); +if strlength(exception.identifier) == 0 + exception.message = ""; + exception.stack = strings(0, 1); + return; +end +exception.message = "Exception captured."; +stack = string(exception.stack(:)); +for index = 1:numel(stack) + marker = strfind(stack(index), " ("); + if ~isempty(marker) + stack(index) = extractBefore(stack(index), marker(1)); + end + try + stack(index) = ... + labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + stack(index), "exceptionStackName"); + catch + stack(index) = "unknown"; + end +end +exception.stack = stack; +end + function value = ternary(condition, trueValue, falseValue) if condition value = trueValue; diff --git a/+labkit/+app/+internal/SessionJournal.m b/+labkit/+app/+internal/SessionJournal.m index 0ea1167fe..15f8598c2 100644 --- a/+labkit/+app/+internal/SessionJournal.m +++ b/+labkit/+app/+internal/SessionJournal.m @@ -1,5 +1,5 @@ classdef (Hidden, Sealed) SessionJournal < handle - %SESSIONJOURNAL Buffered writer for already-validated canonical events. + %SESSIONJOURNAL Buffered writer for full-detail canonical events. % This private projection owns one live session only. Archive inspection, % recovery, retention, and export belong to SessionJournalArchive. diff --git a/+labkit/+app/+internal/SessionJournalArchive.m b/+labkit/+app/+internal/SessionJournalArchive.m index 8afc9b0f9..6db42eb3c 100644 --- a/+labkit/+app/+internal/SessionJournalArchive.m +++ b/+labkit/+app/+internal/SessionJournalArchive.m @@ -1,7 +1,7 @@ classdef (Hidden, Sealed) SessionJournalArchive %SESSIONJOURNALARCHIVE Inspect, recover, retain, and export closed journals. % This private archive boundary never receives live events or decides their - % privacy semantics; it consumes only the canonical journal representation. + % privacy semantics; journals retain the complete canonical representation. methods (Static) function inspection = inspect(rootFolder, varargin) @@ -462,10 +462,8 @@ function removeSession(folder) end function value = redactionMetadata() -value = struct("semanticEventPrivacy", "validated-before-retention", ... - "exportProjection", "canonical-safe-events-only", ... - "excludedData", ["paths", "filenames", "input-content", ... - "scientific-data", "workspace-values"]); +value = struct("semanticEventPrivacy", "complete-retained-details", ... + "exportProjection", "none", "excludedData", strings(0, 1)); end function writeEvents(filepath, events) diff --git a/+labkit/+app/+internal/SessionLogViewer.m b/+labkit/+app/+internal/SessionLogViewer.m index ff5aa21da..45ca7ad6b 100644 --- a/+labkit/+app/+internal/SessionLogViewer.m +++ b/+labkit/+app/+internal/SessionLogViewer.m @@ -202,7 +202,7 @@ function createFigure(obj) obj.DetailArea = uitextarea(root, ... Editable="off", ... - Value="Select an event to inspect safe structured details.", ... + Value="Select an event to inspect complete structured details.", ... FontName="Consolas", ... Tag="labkitSessionLogDetail"); obj.DetailArea.Layout.Row = 5; @@ -231,7 +231,7 @@ function applyFilters(obj) function clearView(obj) obj.Projection.clearView(); obj.DetailArea.Value = ... - "Select an event to inspect safe structured details."; + "Select an event to inspect complete structured details."; obj.CopyButton.Enable = "off"; obj.refreshView(); end @@ -431,6 +431,7 @@ function applySeverityStyle(obj, level, row) "Severity: " + string(record.severity) + ... " Audience: " + string(record.audience) "Category: " + string(record.category) + "Message: " + string(record.message) "Operation: " + string(record.operationId) "Parent: " + string(record.parentOperationId) "Root action: " + string(record.rootActionId) @@ -438,6 +439,7 @@ function applySeverityStyle(obj, level, row) " State: " + string(record.stateDisposition) "Duration (s): " + numericText(record.durationSeconds) "Exception: " + string(exception.identifier) + "Exception message: " + string(exception.message) "Attributes:" attributes "Stack:" diff --git a/+labkit/+app/CallbackContext.m b/+labkit/+app/CallbackContext.m index f806c8563..692b41ff0 100644 --- a/+labkit/+app/CallbackContext.m +++ b/+labkit/+app/CallbackContext.m @@ -35,7 +35,9 @@ % eventName - Stable semantic event identifier. % Category - Semantic App capability category. Default: "workflow". % Audience - "user" or "developer"; default: "user". - % Attributes - Scalar privacy-safe structured details. Default: struct(). + % Attributes - Scalar structured diagnostic details. The Session Log, + % persistent journal, and complete export retain these values. Only + % a user-selected redacted export applies filtering. Default: struct(). % Exception - Scalar MException associated with the event. Default: []. % id - Stable semantic diagnostic or resource identifier. % count - Nonnegative integer diagnostic count. diff --git a/docs/framework/README.md b/docs/framework/README.md index 7d6f48273..364268464 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -165,7 +165,10 @@ utilities do not compete with the App's workflow controls: - **Diagnostics** opens the App-named Session Log or exports a uniquely named bundle beneath `artifacts/diagnostics/`. Every export asks whether to write a redacted log or a complete sensitive log containing current App state; - redacted is the default. + redacted is the default. Runtime collection, the Session Log, and the local + journal retain complete messages, attributes, exception text, and stack + locations. Privacy filtering begins only after the user selects redacted + export; a text fallback preserves the selected redacted or complete mode. These actions are framework-owned native behavior. Apps do not declare menu items, implement clipboard integration, or duplicate project persistence diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 4729c5d57..c0a127372 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -68,8 +68,9 @@ Every current LabKit app exposes one top-level **Tools** menu: document. - **Tools > Diagnostics > Open Session Log...** opens the current App's named live log with Full TRACE, DEBUG, and User views. -- **Tools > Diagnostics > Export Diagnostic Bundle** writes an automatically - named privacy-safe ZIP beneath `artifacts/diagnostics/`. +- **Tools > Diagnostics > Export Diagnostic Bundle** asks for redacted or + complete-sensitive content, then writes an automatically named ZIP beneath + `artifacts/diagnostics/`. State files preserve app projects. They are different from exported result files and from ignored diagnostic manifests under `artifacts/diagnostics/`. diff --git a/docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md b/docs/history/records/2026/08/LK-20260803-app-sdk-diagnostics-and-input-workflows.md similarity index 55% rename from docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md rename to docs/history/records/2026/08/LK-20260803-app-sdk-diagnostics-and-input-workflows.md index 93dd7fed1..5616f9da4 100644 --- a/docs/history/records/2026/08/LK-20260803-session-log-levels-and-automatic-export.md +++ b/docs/history/records/2026/08/LK-20260803-app-sdk-diagnostics-and-input-workflows.md @@ -1,7 +1,7 @@ -# Session logging gains coherent detail levels and automatic export +# App SDK improves diagnostics, artifacts, and input workflows ```labkit-change -id: LK-20260803-session-log-levels-and-automatic-export +id: LK-20260803-app-sdk-diagnostics-and-input-workflows date: 2026-08-03 sequence: 168 type: feat @@ -10,6 +10,8 @@ component: `labkit.app` | `2.1.0 -> 2.2.0` scope: Session Log detail levels scope: Automatic diagnostic export scope: Automatic utility artifacts +scope: File-list validation and failure alerts +scope: Point-only paired-anchor interaction ``` ## Context @@ -21,6 +23,10 @@ detail than DEBUG because capture was off and Runtime emitted few trace stages. Diagnostic ZIP export also asked for a destination before every attempt, while screenshot and project-state saves required manual naming and destination selection. Successful diagnostic export used MATLAB's default error icon. +File collections lacked a shared content predicate and native error-alert +fallback, and the paired-anchor interaction could inherit a connecting path +that implied meaning the App did not own. These were separate development +checkpoints but one pending App SDK transition from the mainline baseline. ## Decision and rationale @@ -30,9 +36,14 @@ TRACE automatically after the first error, and keep an explicit capture toggle inside the owning log window. Give TRACE distinct transaction and presentation stage records. Treat the repository artifacts area as the first destination for diagnostics, screenshots, and project state, generate App-specific names, -and ask for another location only after automatic output fails. Keep retained -events privacy-safe, but let each export explicitly choose between the redacted -bundle and an opt-in sensitive bundle containing current App state. +and ask for another location only after automatic output fails. Retain full +diagnostic details in memory, the viewer, and the local journal. Apply privacy +filtering only after the user explicitly chooses redacted export; complete +export retains those events and additionally includes current App state. +Complete the same SDK transition with a domain-neutral file-list predicate, +aggregate rejection notice, native file-action failure alert, and an explicit +point-only paired-anchor mode. Apps continue to own file-type meaning, alert +wording they handle directly, and scientific interaction semantics. ## Changes @@ -48,20 +59,30 @@ bundle and an opt-in sensitive bundle containing current App state. `artifacts/diagnostics/`; its text fallback uses the same base name, and a prefilled save dialog appears only when automatic output fails. - Each diagnostic export prompts for redacted or complete-sensitive content; - complete export adds the current project/session state as `app-state.mat`. + complete export preserves full events and adds the current project/session + state as `app-state.mat`. ZIP fallback inherits the chosen privacy mode. - Screenshot and project-state saves now generate App-specific names beneath `artifacts/screenshots/` and `artifacts/states/`, with chooser fallback only when automatic output fails. - Successful utility exports use an information icon rather than MATLAB's default error icon. +- File lists can apply a caller-owned predicate to newly proposed paths, + preserve accepted sources, and report aggregate kept/rejected counts without + retaining rejected filenames or paths. +- Native file-panel actions surface otherwise-unhandled validation or parsing + failures in an error alert after transactional rollback. +- Paired-anchor interactions force point rendering without a connecting path. ## User and data impact Users can distinguish concurrent App logs, select a meaningful amount of detail with one control, and export diagnostics without choosing a path. Redacted -diagnostics remain limited to validated privacy-safe Runtime records. Complete -export is explicit and may contain projects, inputs, results, paths, filenames, -and decoded images; external source files and screenshots remain excluded. +diagnostics are filtered only when that export is selected. The local journal, +Session Log, complete export, and complete fallback may contain projects, +inputs, results, paths, filenames, exception locations, and decoded images; +external source files and screenshots remain excluded from bundles. +Apps can accept mixed batch selections without losing compatible inputs, and +unhandled source failures are visible instead of remaining callback output. ## Compatibility and migration @@ -75,17 +96,25 @@ is available in the Session Log window. Focused headless specifications cover three-level projection, automatic trace activation, distinct trace stages, generated ZIP and fallback names, redacted -default export, and explicit state-inclusive export. Hidden-GUI specifications -cover App-specific titles, the single level selector, viewer-local TRACE -control, continuous follow, removed duplicate controls, exports from both entry -points, and automatic screenshot/project-state artifacts. +default export, full-detail retention, explicit state-inclusive export, and +privacy-mode-preserving fallback. Hidden-GUI specifications cover App-specific +titles, the single level selector, viewer-local TRACE control, continuous +follow, complete event inspection, exports from both entry points, and +automatic screenshot/project-state artifacts. App SDK source +evidence also covers file-predicate masks, aggregate notices, preserved source +alignment, native failure alerts, and point-only paired anchors. ## Evidence - `labkittest.run(File="+labkit/+app/+internal/SessionLogProjection.m")` - `labkittest.run(File="+labkit/+app/+internal/SessionLogViewer.m")` - `labkittest.run(File="+labkit/+app/+internal/SessionDiagnosticBundle.m")` -- Deterministic documentation generation and authored-link validation. +- Five focused logging/journal specifications passed 62 headless identities; + the Session Log viewer specification passed 4 hidden-GUI identities. +- App SDK and cross-App file-entry focused specifications passed 64 identities. +- DIC hidden-GUI evidence covered point-only paired anchors and mask activation. +- Authored-link validation passed; full deterministic documentation rendering + remains part of final PR validation. ## Known limitations and follow-up diff --git a/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md b/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md index 26c471ac4..e6c211ed4 100644 --- a/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md +++ b/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md @@ -7,7 +7,6 @@ sequence: 167 type: fix compatibility: compatible component: `labkit_DICPreprocess_app` | `1.7.1 -> 1.7.2` -component: `labkit.app` scope: DIC preprocess interaction repair scope: Rigid image registration ``` @@ -75,7 +74,7 @@ finer structural scoring recovered the same rotation neighborhood. - `labkittest.run(Owner="apps/dic/dic_preprocess/analysisrun", Contract="scientific")` - `labkittest.run(Owner="apps/dic/dic_preprocess/workbench", Contract="presentation")` -- The privacy-safe diagnostic bundle reported +- The redacted diagnostic bundle reported `labkit:app:runtime:InvalidPointSlotsValue` from mask activation. ## Known limitations and follow-up diff --git a/docs/history/records/2026/08/LK-20260803-file-selection-validation.md b/docs/history/records/2026/08/LK-20260803-file-selection-validation.md index a97c2361a..7d9005a80 100644 --- a/docs/history/records/2026/08/LK-20260803-file-selection-validation.md +++ b/docs/history/records/2026/08/LK-20260803-file-selection-validation.md @@ -6,13 +6,12 @@ date: 2026-08-03 sequence: 170 type: fix compatibility: compatible -component: `labkit.app` component: `labkit_ChronoOverlay_app` | `1.6.1 -> 1.6.2` component: `labkit_CIC_app` | `1.6.1 -> 1.6.2` component: `labkit_CSC_app` | `1.6.1 -> 1.6.2` component: `labkit_EIS_app` | `1.6.1 -> 1.6.2` component: `labkit_VTResistance_app` | `1.6.1 -> 1.6.2` -component: `labkit_BatchCrop_app` | `1.9.2 -> 1.9.3` +component: `labkit_BatchImageCrop_app` | `1.9.2 -> 1.9.3` component: `labkit_FLIRThermal_app` | `1.6.1 -> 1.6.2` component: `labkit_FocusStack_app` | `1.7.1 -> 1.7.2` component: `labkit_ImageEnhance_app` | `1.8.1 -> 1.8.2` @@ -21,7 +20,7 @@ component: `labkit_FigureStudio_app` | `0.7.2 -> 0.7.3` component: `labkit_RHSPreview_app` | `1.6.1 -> 1.6.2` scope: Electrochemistry multi-file import scope: Folder and recursive DTA filtering -scope: App SDK file-list path predicates +scope: Shared file-list predicate adoption scope: Cross-App file chooser consistency scope: FLIR radiometric candidate validation ``` @@ -35,31 +34,19 @@ containing another Gamry experiment type caused session reconstruction to fail transactionally and discarded compatible files in the same batch. FLIR Thermal, Batch Crop, and Figure Studio also represented file collections with single-selection controls, while several file buttons still duplicated -legacy folder wording despite separate folder actions. File-panel parsing -exceptions had no framework-owned alert fallback. +legacy folder wording despite separate folder actions. ## Decision and rationale -Extend the existing App SDK file-list contract with a domain-neutral batch -path predicate and a standard aggregate filtering notice. Keep experiment -type detection in each electrochemistry App through the DTA facade: the SDK -owns selection lifecycle and interaction consistency, while Apps retain the -scientific meaning of chrono, CV/CT, and EIS inputs. -Apply the same acquisition contract across every public App: collections are -multi-select, semantic one-file slots remain explicitly bounded, and file -buttons do not claim folder behavior. Keep radiometric acceptance in FLIR -Thermal through the thermal facade, and let the private native adapter surface -otherwise-unhandled file-action failures without changing App-owned wording. +Adopt the shared file-list predicate while keeping experiment type detection +in each electrochemistry App through the DTA facade. Apply the same acquisition +contract across every public App: collections are multi-select, semantic +one-file slots remain explicitly bounded, and file buttons do not claim folder +behavior. Keep radiometric acceptance in FLIR Thermal through the thermal +facade. ## Changes -- Added `PathFilter` and `PathFilterDescription` to - `labkit.app.layout.fileList`. -- Applied predicates only to newly proposed paths, retained previously - accepted sources, validated the returned logical mask, and omitted rejected - paths before portable source records were created. -- Added one aggregate, filename-free notice when unsupported files are - filtered. - Enabled native multi-file selection for CIC, CSC, and VT Resistance. - Declared chrono, CV/CT, or EIS predicates for all five electrochemistry Apps. - Enabled multi-file selection for FLIR Thermal, Batch Crop, and Figure Studio. @@ -67,8 +54,6 @@ otherwise-unhandled file-action failures without changing App-owned wording. neurophysiology Apps while preserving the separate folder/tree controls. - Added FLIR content-level candidate inspection so ordinary JPEGs, unreadable payloads, and wrong file types are omitted with the standard aggregate alert. -- Added a native file-panel error fallback so an unhandled parsing or - validation failure is presented in an alert after transactional rollback. ## User and data impact @@ -90,10 +75,8 @@ result schemas, and export values are unchanged. ## Validation -Focused App SDK source specifications cover callback signature validation, -batch mask application, preservation of existing sources, portable-source -alignment, and aggregate notice wording. App-owned source specifications cover -chrono, CV/CT, and EIS discrimination. One existing hidden-GUI workflow per +App-owned source specifications cover chrono, CV/CT, and EIS discrimination. +One existing hidden-GUI workflow per electrochemistry App covers the mixed batch through plotting, analysis, export, and project restore; CIC, CSC, and VT Resistance also verify native multiple selection. diff --git a/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m index b6d886d2f..b19c65509 100644 --- a/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m +++ b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m @@ -2,7 +2,7 @@ % SESSIONDIAGNOSTICBUNDLESPEC Specify redacted and opt-in sensitive bundles. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) - function exportsTheExactSafeBundleFromAnActiveSession(testCase) + function exportsTheExactRedactedBundleFromAnActiveSession(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; definition = bundleDefinition(); @@ -48,7 +48,7 @@ function exportsTheExactSafeBundleFromAnActiveSession(testCase) fullfile(unpacked, "redaction-report.json"))); testCase.verifyEqual( ... string(redaction.exportProjection), ... - "canonical-safe-events-only"); + "redacted-at-export"); testCase.verifyTrue(any( ... string(redaction.excludedData) == "scientific-data")); @@ -59,7 +59,7 @@ function exportsTheExactSafeBundleFromAnActiveSession(testCase) clear cleanup end - function fallsBackToSafeMemoryWhenTheJournalIsUnavailable(testCase) + function fallsBackToRedactedMemoryWhenTheJournalIsUnavailable(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; definition = bundleDefinition(); @@ -96,6 +96,7 @@ function explicitlyIncludesCurrentAppStateOnlyWhenRequested(testCase) runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... definition, [], struct(), [], JournalRoot=folder); cleanup = onCleanup(@() runtime.close()); + runtime.invokeAction("run"); destination = runtime.exportDiagnosticBundle( ... fullfile(folder, "sensitive.zip"), true); @@ -111,7 +112,14 @@ function explicitlyIncludesCurrentAppStateOnlyWhenRequested(testCase) ["project"; "session"]); testCase.verifyTrue(redaction.includesPrivateAppState); testCase.verifyEqual(string(redaction.exportProjection), ... - "canonical-safe-events-plus-opt-in-app-state"); + "complete-retained-events-plus-opt-in-app-state"); + bundleText = join(readAllBundleText(unpacked, [ ... + "README.txt"; "events.jsonl"; "session.log.txt"; ... + "errors.json"]), newline); + testCase.verifyTrue(contains(bundleText, ... + "/synthetic/private-source.png")); + testCase.verifyTrue(contains(bundleText, ... + "Synthetic incident at /synthetic/private-source.png.")); clear cleanup end @@ -148,6 +156,29 @@ function writesOneReadableTextFallbackBesideTheAutomaticZip(testCase) clear fileCleanup cleanup end + function completeTextFallbackPreservesFullDetails(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + bundleDefinition(), [], struct(), [], JournalRoot=folder); + cleanup = onCleanup(@() runtime.close()); + runtime.invokeAction("run"); + + destination = runtime.exportDiagnosticTextFallback( ... + fullfile(folder, "diagnostics-sensitive.zip"), ... + MException("labkit:test:ZipFailure", ... + "Synthetic ZIP failure."), true); + fallback = string(fileread(destination)); + + testCase.verifyTrue(contains(fallback, ... + "preserves the selected complete-log mode")); + testCase.verifyTrue(contains(fallback, ... + "/synthetic/private-source.png")); + testCase.verifyTrue(contains(fallback, ... + "Synthetic incident at /synthetic/private-source.png.")); + clear cleanup + end + function automaticExportUsesArtifactsAndDoesNotAskForAPath(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; @@ -210,14 +241,16 @@ function automaticExportUsesArtifactsAndDoesNotAskForAPath(testCase) Attributes=struct("enum", "synthetic")); callbackContext.log( ... "info", "analysis.completed", ... - "Synthetic analysis completed.", ... - Category=category, Audience="user"); + "Synthetic analysis completed for /synthetic/private-source.png.", ... + Category=category, Audience="user", ... + Attributes=struct("sourcePath", "/synthetic/private-source.png")); callbackContext.log( ... "error", "analysis.failed", ... "Synthetic analysis failed.", ... Category=category, Audience="user", ... Exception=MException( ... - "labkit:test:SyntheticIncident", "Synthetic incident.")); + "labkit:test:SyntheticIncident", ... + "Synthetic incident at /synthetic/private-source.png.")); end function events = readEvents(folder) diff --git a/tests/specs/labkit/app/SessionEventStreamSpec.m b/tests/specs/labkit/app/SessionEventStreamSpec.m index 8a03fa8cb..cab461bd2 100644 --- a/tests/specs/labkit/app/SessionEventStreamSpec.m +++ b/tests/specs/labkit/app/SessionEventStreamSpec.m @@ -12,7 +12,7 @@ function defaultSessionIdentityDoesNotChangeRng(testCase) clear cleanup end - function retainsMinimalPrivacySafeEventBeforeAnyProjection(testCase) + function retainsCanonicalEventBeforeAnyProjection(testCase) stream = labkit.app.internal.SessionEventStream( ... loggingProbeDefinition(), SessionId="session-test"); cleanup = onCleanup(@() stream.close()); @@ -41,39 +41,28 @@ function retainsMinimalPrivacySafeEventBeforeAnyProjection(testCase) clear cleanup end - function rejectsRawPathBeforeItCanEnterTheRetainedRing(testCase) + function retainsRawPathsInRingAndProjection(testCase) testfixtures.StateStore.set("eventConsumerRecords", strings(0, 1)); resetConsumer = onCleanup(@resetTestConsumer); - folder = string(testCase.applyFixture( ... - matlab.unittest.fixtures.TemporaryFolderFixture).Folder); - leaf = "input" + "." + "csv"; - windowsPath = string(fullfile(folder, leaf)); - posixPath = "/" + replace(erase(folder, ":"), "\\", "/") + "/" + leaf; - posixSingleComponent = "/" + "artifact"; - separator = string(char(92)); - uncPath = separator + separator + "node" + separator + "share" + ... - separator + leaf; - unsafeValues = [windowsPath, posixPath, posixSingleComponent, uncPath, leaf]; + sourcePath = "/synthetic/input.csv"; stream = labkit.app.internal.SessionEventStream( ... loggingProbeDefinition(), ProjectionHook=@recordTestConsumer); cleanup = onCleanup(@() stream.close()); testfixtures.StateStore.set("eventConsumerRecords", strings(0, 1)); - before = numel(stream.records()); - - for unsafeValue = unsafeValues - testCase.verifyError(@() stream.log("info", "source.loaded", ... - "Loaded " + unsafeValue + ".", ... - Category="runtime.source", Audience="developer"), ... - "labkit:app:contract:UnsafeLogData"); - testCase.verifyError(@() stream.log("info", "source.loaded", ... - "Selected source loaded.", Category="runtime.source", ... - Audience="developer", Attributes=struct("source", ... - struct("selection", unsafeValue))), ... - "labkit:app:contract:UnsafeLogData"); - end - testCase.verifyEqual(numel(stream.records()), before); - testCase.verifyEmpty(testfixtures.StateStore.get("eventConsumerRecords")); + stream.log("info", "source.loaded", ... + "Loaded " + sourcePath + ".", ... + Category="runtime.source", Audience="developer", ... + Attributes=struct("sourcePath", sourcePath)); + + records = stream.records(); + retained = records(end); + testCase.verifyEqual(retained.message, ... + "Loaded /synthetic/input.csv."); + testCase.verifyEqual(retained.attributes.sourcePath, sourcePath); + testCase.verifyEqual( ... + testfixtures.StateStore.get("eventConsumerRecords"), ... + "source.loaded"); clear cleanup resetConsumer end @@ -152,44 +141,25 @@ function retainsOnlyTheFixedAttributeGrammarAtItsBoundaries(testCase) clear cleanup end - function rejectsUnsafeAttributeShapesAndSemanticsBeforeRetention(testCase) + function acceptsSerializableDiagnosticAttributeShapes(testCase) stream = labkit.app.internal.SessionEventStream( ... loggingProbeDefinition()); cleanup = onCleanup(@() stream.close()); - before = numel(stream.records()); - rejected = { ... - struct("count", [1, 2]), struct("count", NaN), ... - struct("count", Inf), struct("count", logical([true, false])), ... - struct("enum", {{"safe"}}), struct("count", table(1)), ... - struct("count", datetime("now")), struct("count", @sin), ... - struct("freeText", "safe-token"), struct("subject", 1), ... - struct("bindingId", "run"), struct("sourceAlias", "untrusted-token"), ... - struct("sourceAlias", "source-" + string(repmat('1', 1, 64))), ... - struct("enum", string(missing)), ... - struct("sampleCount", "one"), ... - struct("unit", "10mV"), struct("unit", "mV per s"), ... - struct("nested", struct("count", 1)), ... - struct("dimensions", struct()), ... - struct("dimensions", struct("x", 0)), ... - struct("dimensions", struct("x", [1, 2])), ... - dimensionsWithFiveAxes(), attributesWithSeventeenFields(), ... - attributesWithThirteenRootFieldsAndFourAxes(), ... - attributesAtCanonicalByteCount(1025)}; - if namelengthmax > 63 - rejected{end + 1} = attributeWithOverlongKey(); - end + attributes = struct("values", [1 2 3], ... + "nested", struct("sourcePath", "/synthetic/input.csv"), ... + "labels", ["alpha", "beta"]); - for index = 1:numel(rejected) - testCase.verifyError(@() stream.log("info", "analysis.rejected", ... - "Rejected attribute payload.", Category="runtime.lifecycle", ... - Audience="developer", Attributes=rejected{index}), ... - "labkit:app:contract:UnsafeLogData"); - end - testCase.verifyEqual(numel(stream.records()), before); + stream.log("info", "analysis.recorded", ... + "Recorded complete diagnostic attributes.", ... + Category="runtime.lifecycle", Audience="developer", ... + Attributes=attributes); + + records = stream.records(); + testCase.verifyEqual(records(end).attributes, attributes); clear cleanup end - function rejectsUnsafeAttributesWithoutChangingRingHookOrJournal(testCase) + function persistsCompleteAttributesToTheJournal(testCase) root = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; journal = labkit.app.internal.SessionJournal(loggingProbeDefinition(), ... @@ -204,17 +174,18 @@ function rejectsUnsafeAttributesWithoutChangingRingHookOrJournal(testCase) ProjectionHook=@persistAttributePrivacyRecord); streamCleanup = onCleanup(@() stream.close()); testfixtures.StateStore.set("attributePrivacyHookCount", 0); - beforeRecords = numel(stream.records()); - beforeJournal = journalText(journal.folder()); - - testCase.verifyError(@() stream.log("info", "analysis.rejected", ... - "Rejected attribute payload.", Category="runtime.lifecycle", ... - Audience="developer", Attributes=struct("sampleId", 1)), ... - "labkit:app:contract:UnsafeLogData"); - - testCase.verifyEqual(numel(stream.records()), beforeRecords); - testCase.verifyEqual(testfixtures.StateStore.get("attributePrivacyHookCount"), 0); - testCase.verifyEqual(journalText(journal.folder()), beforeJournal); + stream.log("info", "analysis.recorded", ... + "Loaded /synthetic/input.csv.", Category="runtime.lifecycle", ... + Audience="developer", Attributes=struct( ... + "sourcePath", "/synthetic/input.csv", "values", [1 2 3])); + journal.flush(); + + retained = journalText(journal.folder()); + testCase.verifyEqual( ... + testfixtures.StateStore.get("attributePrivacyHookCount"), 1); + testCase.verifyTrue(contains(retained, "/synthetic/input.csv")); + testCase.verifyTrue(contains(retained, "values")); + testCase.verifyTrue(contains(retained, "[1,2,3]")); clear streamCleanup journalCleanup globalCleanup end diff --git a/tests/specs/labkit/app/SessionJournalSpec.m b/tests/specs/labkit/app/SessionJournalSpec.m index 81b8a8a64..9d379bb7d 100644 --- a/tests/specs/labkit/app/SessionJournalSpec.m +++ b/tests/specs/labkit/app/SessionJournalSpec.m @@ -433,7 +433,7 @@ function inspectionOnlyAbandonsConfirmedStaleSessions(testCase) testCase.verifyTrue(isfile(fullfile(exportFolder, "degradation.json"))); redaction = readJson(exportFolder, "redaction.json"); testCase.verifyEqual(string(redaction.exportProjection), ... - "canonical-safe-events-only"); + "none"); bundleText = join([string(fileread(fullfile(exportFolder, "events.jsonl"))); ... string(fileread(fullfile(exportFolder, "timeline.txt")))], newline); testCase.verifyFalse(contains(bundleText, string(root))); diff --git a/tests/specs/labkit/app/SessionLogViewerSpec.m b/tests/specs/labkit/app/SessionLogViewerSpec.m index 47d29d697..78a480e50 100644 --- a/tests/specs/labkit/app/SessionLogViewerSpec.m +++ b/tests/specs/labkit/app/SessionLogViewerSpec.m @@ -109,6 +109,8 @@ function inspectsEarlierDebugFiltersLongMessagesAndClearsOnlyView(testCase) string(detail.Value), "analysis.failed"))); testCase.verifyTrue(any(contains( ... string(detail.Value), "labkit:test:SyntheticIncident"))); + testCase.verifyTrue(any(contains( ... + string(detail.Value), "Synthetic incident."))); clearButton = oneHandle( ... viewerFigure, "labkitSessionLogClear"); diff --git a/tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m b/tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m index 765cea050..f806a7f6b 100644 --- a/tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m +++ b/tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m @@ -1,28 +1,27 @@ classdef SessionLoggingPrivacyContractSpec < matlab.unittest.TestCase - %SESSIONLOGGINGPRIVACYCONTRACTSPEC Freeze App-facing retained-data privacy rules. + %SESSIONLOGGINGPRIVACYCONTRACTSPEC Freeze full-detail logging boundary. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) - function rejectsRawPathsBeforeInvokingAnyLoggingBackend(testCase) - context = labkit.app.internal.CallbackContextFactory.disconnected(); - folder = testCase.applyFixture( ... - matlab.unittest.fixtures.TemporaryFolderFixture).Folder; - syntheticPath = string(fullfile(folder, "input.csv")); + function passesCompleteDetailsToTheLoggingBackend(testCase) + captured = cell(1, 7); + context = labkit.app.internal.CallbackContextFactory.create( ... + struct("log", @captureLog)); + syntheticPath = "/synthetic/input.csv"; - testCase.verifyError(@() context.log("info", "source.loaded", ... - "Loaded " + syntheticPath + ".", ... - Category="sourceFiles"), "labkit:app:contract:UnsafeLogData"); - end + context.log("info", "source.loaded", ... + "Loaded " + syntheticPath + ".", Category="sourceFiles", ... + Audience="developer", ... + Attributes=struct("sourcePath", syntheticPath, ... + "values", [1 2 3])); - function rejectsRawFilenamesInAttributesBeforeInvokingAnyLoggingBackend(testCase) - context = labkit.app.internal.CallbackContextFactory.disconnected(); - folder = testCase.applyFixture( ... - matlab.unittest.fixtures.TemporaryFolderFixture).Folder; - syntheticPath = string(fullfile(folder, "input.csv")); + testCase.verifyEqual(captured{3}, ... + "Loaded /synthetic/input.csv."); + testCase.verifyEqual(captured{6}.sourcePath, syntheticPath); + testCase.verifyEqual(captured{6}.values, [1 2 3]); - testCase.verifyError(@() context.log("info", "source.loaded", ... - "Selected source loaded.", Category="sourceFiles", ... - Attributes=struct("sourcePath", syntheticPath)), ... - "labkit:app:contract:UnsafeLogData"); + function captureLog(varargin) + captured = varargin; + end end end end diff --git a/tests/specs/labkit/app/SessionLoggingRuntimeSpec.m b/tests/specs/labkit/app/SessionLoggingRuntimeSpec.m index 6f857e46f..f50c27caa 100644 --- a/tests/specs/labkit/app/SessionLoggingRuntimeSpec.m +++ b/tests/specs/labkit/app/SessionLoggingRuntimeSpec.m @@ -111,7 +111,8 @@ function persistsRollbackFailureChainOnClose(testCase) testCase.verifyEqual(string(failed.operationResult), "failed"); testCase.verifyEqual(string(failed.stateDisposition), "rolledBack"); testCase.verifyEqual(string(failed.exception.identifier), "probe:ExpectedFailure"); - testCase.verifyEqual(string(failed.exception.message), "Exception captured."); + testCase.verifyEqual(string(failed.exception.message), ... + "Expected rollback failure."); testCase.verifyEqual(string(snapshot.manifest.state), "closed"); clear cleanup end From b8bd37c994e519d192af87fb7d4e4285d036a1a1 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 15:05:41 -0500 Subject: [PATCH 11/32] chore: enforce squash pr history coherence --- .agents/skills/labkit-pr-preparer/SKILL.md | 98 +++++++++++++++++++ .../labkit-pr-preparer/agents/openai.yaml | 4 + .github/scripts/check_integration_policy.py | 75 ++++++++++++-- .../scripts/test_check_integration_policy.py | 67 +++++++++++++ AGENTS.md | 6 +- .../maintain-and-release/release.md | 10 +- 6 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 .agents/skills/labkit-pr-preparer/SKILL.md create mode 100644 .agents/skills/labkit-pr-preparer/agents/openai.yaml diff --git a/.agents/skills/labkit-pr-preparer/SKILL.md b/.agents/skills/labkit-pr-preparer/SKILL.md new file mode 100644 index 000000000..9a9caa29a --- /dev/null +++ b/.agents/skills/labkit-pr-preparer/SKILL.md @@ -0,0 +1,98 @@ +--- +name: labkit-pr-preparer +description: "Prepare LabKit develop for a squash PR into main by auditing the complete base-to-head diff, consolidating component versions and structured history, running the one final local gate, and assembling the repository PR record. Use only when the user asks to prepare, open, update, review, or make merge-ready a develop-to-main PR; do not use during ordinary branch iteration." +--- + +# LabKit PR Preparer + +Treat `origin/main..develop` as one proposed product change. Intermediate +commits, temporary versions, and checkpoint history files are working state; +the merge-ready tree must describe one coherent squash result. + +## Read + +Read `AGENTS.md`, the nearest changed-component rules, +`docs/development/maintain-and-release/release.md`, +`docs/development/maintain-and-release/testing.md`, +`docs/history/record-format.md`, and `.github/pull_request_template.md`. +Use `labkit-documentation-maintainer` when rewriting component history and +`labkit-test-planner` for the final local gate. + +## Establish the PR boundary + +1. Fetch `origin` with host network permission. +2. Require the canonical `develop` branch and a clean understood worktree. +3. Record `origin/main`, `develop`, `origin/develop`, the complete + `origin/main...develop` diff, and the intermediate commit list. +4. Stop if `develop` was not created from the current main delivery stream, + an existing develop-to-main PR already freezes a different head, or + unrelated local work cannot be separated safely. + +Do not merge `main` into `develop`, create a sync commit, force-push, or rewrite +Git commits without explicit approval. PR preparation rewrites the proposed +tree and authored component history; GitHub performs the final squash. + +## Consolidate versions and history + +Build one inventory of every changed App definition, facade `version.m`, +launcher metadata file, manual, and structured history record. + +- Derive every final component version directly from `origin/main`, never from + an intermediate develop version. Choose exactly one direct patch, minor, or + major step for the net behavior. +- Delete intermediate transitions such as `2.1.0 -> 2.2.0` followed by + `2.2.0 -> 2.3.0`. The merge-ready history contains only the chosen direct + main-baseline-to-PR-final transition. +- Require one changed structured history record per versioned component. A + cross-component decision uses one record listing all affected components. + Merge related checkpoint records; remove tiny mechanical records and + duplicate unversioned component references that fragment the same PR story. +- Rewrite titles, IDs, filenames, scopes, rationale, compatibility, evidence, + and follow-up as the net delivered behavior. Preserve published mainline + history; freely consolidate records introduced only on `develop` while + keeping global sequence metadata valid. +- Update each owning manual once for the net public behavior. Do not repeat + framework defaults in App manuals or preserve prose that merely narrates + intermediate commits. + +Inspect the result manually even when policy automation passes. Automation can +prove exact transitions and record presence; it cannot decide whether two +records tell one logical product story. + +## Run merge-readiness checks + +Run the integration policy against the actual proposed refs before broad +MATLAB validation: + +```bash +python3 .github/scripts/check_integration_policy.py \ + --event-name pull_request \ + --base-ref main \ + --head-ref develop \ + --head-repository Pluze/LabKit-MATLAB-Workbench \ + --repository Pluze/LabKit-MATLAB-Workbench \ + --base-sha origin/main \ + --head-sha develop +``` + +Then: + +1. Run authored-link maintenance after moved Markdown and review rewrites. +2. Run `changedFast` exactly once for the final merge-ready tree. +3. Inspect the complete diff, data hygiene, component versions, structured + history, manuals, test evidence, and remaining native/manual checks. +4. Fill the repository PR template with net behavior and exact evidence. +5. Push the final develop checkpoint, open or update the PR, and freeze + `develop` until the PR is merged or closed. +6. Read only failing CI logs. Repair the narrowest failure and rerun its exact + evidence; do not repeat `changedFast` after every repair. + +Do not declare merge readiness when the policy audit, final local gate, +required PR CI, review, or conversation resolution is incomplete. + +## Handoff + +Report the base and head SHAs, consolidated version transitions and history +records, final local evidence, PR/CI state, manual checks, data-hygiene result, +develop freeze state, and any blocker. Distinguish completed automated proof +from developer-led interactive validation. diff --git a/.agents/skills/labkit-pr-preparer/agents/openai.yaml b/.agents/skills/labkit-pr-preparer/agents/openai.yaml new file mode 100644 index 000000000..7bf8464fb --- /dev/null +++ b/.agents/skills/labkit-pr-preparer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "LabKit PR Preparer" + short_description: "Audit and consolidate a LabKit develop-to-main PR" + default_prompt: "Use $labkit-pr-preparer to prepare develop for a squash PR into main." diff --git a/.github/scripts/check_integration_policy.py b/.github/scripts/check_integration_policy.py index b0389cdd1..73e6b7d23 100644 --- a/.github/scripts/check_integration_policy.py +++ b/.github/scripts/check_integration_policy.py @@ -25,6 +25,11 @@ re.DOTALL, ) LAUNCHER_METADATA = "+labkit/+app/+internal/+launcher/dispatch.m" +HISTORY_COMPONENT = re.compile( + r"^component:\s*`([^`]+)`" + r"(?:\s*\|\s*`([^`]+)\s*->\s*([^`]+)`)?\s*$", + re.MULTILINE, +) def command(*arguments: str, allow_missing: bool = False) -> str | None: @@ -101,6 +106,21 @@ def metadata_path_for_source(path: str) -> str | None: return None +def parse_history_components( + source: str | None, +) -> list[tuple[str, str | None, str | None]]: + if source is None: + return [] + return [ + ( + component.strip(), + before.strip() if before else None, + after.strip() if after else None, + ) + for component, before, after in HISTORY_COMPONENT.findall(source) + ] + + def validate_branch( event_name: str, base_ref: str, @@ -163,21 +183,60 @@ def validate_versions( ) transitions.append((component_after, version_before, version_after)) - history = "\n".join( - read_head(path) or "" + history_records = { + path: parse_history_components(read_head(path)) for path in paths if path.startswith("docs/history/records/") and path.endswith(".md") - ) + } + net_transitions = { + component: (before, after) + for component, before, after in transitions + } for component, before, after in transitions: - expected = re.compile( - rf"component:\s*`{re.escape(component)}`\s*\|\s*" - rf"`{re.escape(before)}\s*->\s*{re.escape(after)}`" - ) - if not expected.search(history): + occurrences = [ + (path, recorded_before, recorded_after) + for path, records in history_records.items() + for recorded_component, recorded_before, recorded_after in records + if recorded_component == component + ] + record_paths = sorted({path for path, _, _ in occurrences}) + if len(record_paths) > 1: + errors.append( + f"{component}: changed history is split across " + f"{', '.join(record_paths)}; consolidate the component's " + "net PR history into one record." + ) + exact = [ + item for item in occurrences + if item[1:] == (before, after) + ] + if not exact: errors.append( f"{component}: history must record `{before} -> {after}` " "in this change." ) + elif len(exact) > 1: + errors.append( + f"{component}: history records `{before} -> {after}` more " + "than once; keep one net transition." + ) + for path, records in history_records.items(): + for component, before, after in records: + if before is None: + continue + expected = net_transitions.get(component) + if expected is None: + errors.append( + f"{path}: history records `{component}` as " + f"`{before} -> {after}`, but the component has no net " + "version change from the PR base." + ) + elif expected != (before, after): + errors.append( + f"{path}: history records `{component}` as " + f"`{before} -> {after}`, but the net PR transition is " + f"`{expected[0]} -> {expected[1]}`." + ) return errors diff --git a/.github/scripts/test_check_integration_policy.py b/.github/scripts/test_check_integration_policy.py index 1adac525e..5a600ca95 100644 --- a/.github/scripts/test_check_integration_policy.py +++ b/.github/scripts/test_check_integration_policy.py @@ -109,6 +109,73 @@ def test_facade_double_jump_is_rejected(self): ], ) + def test_history_rejects_intermediate_and_split_component_records(self): + version_path = "+labkit/+app/version.m" + first_history = "docs/history/records/2026/08/LK-first.md" + second_history = "docs/history/records/2026/08/LK-second.md" + before = 'labkit.contract.versionInfo("app", "2.1.0", ">=2 <3")' + after = before.replace("2.1.0", "2.2.0") + base = {version_path: before} + head = { + version_path: after, + first_history: "component: `labkit.app` | `2.1.0 -> 2.2.0`", + second_history: "\n".join([ + "component: `labkit.app` | `2.2.0 -> 2.3.0`", + "component: `sample_app` | `1.0.0 -> 1.0.1`", + ]), + } + + errors = MODULE.validate_versions( + [version_path, first_history, second_history], + base.get, + head.get, + ) + + self.assertIn( + "labkit.app: changed history is split across " + f"{first_history}, {second_history}; consolidate the component's " + "net PR history into one record.", + errors, + ) + self.assertIn( + f"{second_history}: history records `labkit.app` as " + "`2.2.0 -> 2.3.0`, but the net PR transition is " + "`2.1.0 -> 2.2.0`.", + errors, + ) + self.assertIn( + f"{second_history}: history records `sample_app` as " + "`1.0.0 -> 1.0.1`, but the component has no net version change " + "from the PR base.", + errors, + ) + + def test_one_consolidated_history_record_accepts_the_net_transition(self): + version_path = "+labkit/+app/version.m" + history_path = "docs/history/records/2026/08/LK-sdk.md" + before = 'labkit.contract.versionInfo("app", "2.1.0", ">=2 <3")' + after = before.replace("2.1.0", "2.2.0") + base = {version_path: before} + head = { + version_path: after, + history_path: "\n".join([ + "component: `labkit.app` | `2.1.0 -> 2.2.0`", + "component: `sample_app` | `1.0.0 -> 1.0.1`", + ]), + } + + errors = MODULE.validate_versions( + [version_path, history_path], base.get, head.get + ) + + self.assertEqual( + errors, + [ + f"{history_path}: history records `sample_app` as " + "`1.0.0 -> 1.0.1`, but the component has no net version " + "change from the PR base." + ], + ) def test_launcher_source_uses_launcher_metadata(self): metadata = MODULE.LAUNCHER_METADATA before = ( diff --git a/AGENTS.md b/AGENTS.md index 720bed02e..c2e474de5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,11 @@ tests, history, and details out of the public repository. merging the final PR, inspect the complete base-to-head diff, user docs, component versions, structured history, validation evidence, and remaining risks as one net change that `main` will squash into; do not derive release - semantics from intermediate branch commits. + semantics from intermediate branch commits. Versions and small history + records may remain provisional during ordinary iteration, but PR preparation + rewrites them from the `origin/main` baseline: remove intermediate version + transitions, merge related checkpoint records, and leave exactly one changed + structured history record for each versioned component. 5. Main accepts PRs only from the repository-owned `develop` branch. Run `changedFast` once before final review, inspect required PR CI, and read only failing logs. Squash-merge with an explicit compliant subject. diff --git a/docs/development/maintain-and-release/release.md b/docs/development/maintain-and-release/release.md index 52b051de2..57bae983e 100644 --- a/docs/development/maintain-and-release/release.md +++ b/docs/development/maintain-and-release/release.md @@ -26,9 +26,13 @@ major with minor and patch zero. The related component history record describes that direct `main baseline -> PR final` transition. CI verifies existing App, facade, and launcher transitions before MATLAB setup. This prevents temporary branch versions from accumulating into artificial public -version jumps. Maintain user documentation with the same squash-oriented view: -it describes the final branch behavior and one net compatibility transition, -not the sequence of intermediate commits used to develop it. +version jumps. Checkpoint history records may also remain provisional during +ordinary branch iteration. Before PR review, merge related records, remove +mechanical fragments and intermediate transitions, and leave each versioned +component in exactly one changed history record. Maintain user documentation +with the same squash-oriented view: it describes the final branch behavior and +one net compatibility transition, not the sequence of intermediate commits +used to develop it. ## Tags From bfdd8d7a0c170ac172facaa9389d6c2471a9e900 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 15:23:58 -0500 Subject: [PATCH 12/32] fix: strengthen dic rigid registration --- .../+analysisRun/autoAlignMovingToReference.m | 284 ++++++++++++++---- .../+analysisRun/previewRequest.m | 5 +- .../+analysisRun/startPointMatching.m | 2 +- .../+sourceFiles/layoutSection.m | 4 +- .../+dic_preprocess/projectSpec.m | 26 +- docs/apps/dic/dic-preprocess/README.md | 17 +- ...process-interaction-registration-repair.md | 42 ++- .../analysisRun/DicPreprocessScientificSpec.m | 64 +++- .../project/DicPreprocessProjectSpec.m | 10 +- .../workbench/DicPreprocessWorkflowSpec.m | 5 +- 10 files changed, 368 insertions(+), 91 deletions(-) diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m index b4e34eac8..a4a3e8f43 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m @@ -18,18 +18,19 @@ % shown as transform in the usage syntax. % method - Character vector identifying the fixed coarse-to-fine method. % quality - Scalar structure containing angleDegrees, translationX, -% translationY, and score for the accepted structural match. +% translationY, score, overlapFraction, scoreMargin, and +% translationPeakMargin for the accepted match. % % Description: % Each image is converted to normalized grayscale independently. The search -% covers -30 through +30 degrees at 1.5-degree spacing and refines the best -% neighborhood at quarter-degree spacing. Each candidate evaluates a -% toolbox-free, zero-padded phase-correlation translation on a bounded -% preview, then ranks the transform using oriented image structure at a -% finer resolution to avoid alias-driven angle selection. The accepted -% rotation and translation are applied to the original moving image. Scale -% and deformation are not estimated; repeated texture and large nonoverlap -% can still produce a poor fit. +% covers the full rotation circle at six-degree spacing, then refines the +% best neighborhood at one-degree and quarter-degree spacing. Anti-aliased +% previews and zero-padded amplitude-weighted phase correlation provide subpixel +% translation estimates. Candidates are ranked with robust, overlap-aware +% oriented structure at a finer resolution. The accepted rotation and +% translation are applied to the original moving image. Scale and +% deformation are not estimated; repeated texture and large nonoverlap can +% still produce a poor fit. % % Failure Behavior: % dic_preprocess:AutoAlignmentFailed - No candidate has a finite oriented @@ -70,28 +71,43 @@ gray = labkit.image.im2double(imageData); end values = gray(:); - values = values(~isnan(values)); + values = values(isfinite(values)); if isempty(values) return; end - mn = min(values); - mx = max(values); + values = sort(values); + mn = percentileValue(values, .01); + mx = percentileValue(values, .99); + if ~(isfinite(mn) && isfinite(mx) && mx > mn) + mn = values(1); + mx = values(end); + end if isfinite(mn) && isfinite(mx) && mx > mn gray = (gray - mn) ./ (mx - mn); + gray = min(1, max(0, gray)); end end +function value = percentileValue(sortedValues, fraction) + position = 1 + fraction * (numel(sortedValues) - 1); + lower = floor(position); + upper = ceil(position); + weight = position - lower; + value = (1 - weight) * sortedValues(lower) + ... + weight * sortedValues(upper); +end + function [transform, quality] = estimateRigidTransform(fixedGray, movingGray) - % DIC camera repositioning is expected to be modest. Searching this - % bounded range and two resolution stages keep interactive registration - % responsive while restoring the rotation capability lost by the - % translation-only fallback. A 256-pixel preview bounds translation work; + % A global-to-fine full-circle search supports camera reorientation while + % keeping the expensive high-resolution scoring bounded. A 256-pixel + % preview bounds translation work; % a finer 1024-pixel structural score avoids selecting angles from an % aliased DIC texture preview without changing the source-resolution % output transform. - maximumExpectedRotationDegrees = 30; - coarseAngleStepDegrees = 1.5; + coarseAngleStepDegrees = 6; + intermediateAngleStepDegrees = 1; fineAngleStepDegrees = .25; + maximumTranslationFraction = .75; maximumPreviewDimension = 256; maximumScoreDimension = 1024; fixedSize = [size(fixedGray, 1), size(fixedGray, 2)]; @@ -104,22 +120,43 @@ maximumScoreDimension)); scoreRows = 1:scoreStep:size(fixedGray, 1); scoreCols = 1:scoreStep:size(fixedGray, 2); - fixedPreview = fixedGray(fixedRows, fixedCols); + fixedTranslationImage = antiAliasForStep(fixedGray, sampleStep); + movingTranslationImage = antiAliasForStep(movingGray, sampleStep); + fixedScoreImage = antiAliasForStep(fixedGray, scoreStep); + movingScoreImage = antiAliasForStep(movingGray, scoreStep); + fixedPreview = fixedTranslationImage(fixedRows, fixedCols); + fixedScorePreview = fixedScoreImage(scoreRows, scoreCols); fixedFeature = registrationFeature(fixedPreview); - coarseAngles = -maximumExpectedRotationDegrees: ... - coarseAngleStepDegrees:maximumExpectedRotationDegrees; - [bestTransform, bestAngle, bestScore] = bestCandidate( ... - coarseAngles, fixedGray, movingGray, fixedFeature, ... - fixedRows, fixedCols, sampleStep, scoreRows, scoreCols); - fineAngles = bestAngle + ... - (-coarseAngleStepDegrees:fineAngleStepDegrees:coarseAngleStepDegrees); - fineAngles = fineAngles(abs(fineAngles) <= maximumExpectedRotationDegrees); - [fineTransform, ~, fineScore] = bestCandidate( ... - fineAngles, fixedGray, movingGray, fixedFeature, ... - fixedRows, fixedCols, sampleStep, scoreRows, scoreCols); - if fineScore > bestScore + coarseAngles = -180:coarseAngleStepDegrees: ... + 180 - coarseAngleStepDegrees; + [bestTransform, bestAngle, bestScore, bestDetails] = bestCandidate( ... + coarseAngles, fixedGray, movingTranslationImage, movingScoreImage, ... + fixedFeature, fixedScorePreview, fixedRows, fixedCols, sampleStep, ... + scoreRows, scoreCols, maximumTranslationFraction); + intermediateAngles = angleNeighborhood(bestAngle, ... + coarseAngleStepDegrees, intermediateAngleStepDegrees); + [intermediateTransform, intermediateAngle, intermediateScore, ... + intermediateDetails] = bestCandidate( ... + intermediateAngles, fixedGray, movingTranslationImage, ... + movingScoreImage, fixedFeature, fixedScorePreview, fixedRows, ... + fixedCols, sampleStep, scoreRows, scoreCols, ... + maximumTranslationFraction); + if intermediateScore >= bestScore + bestTransform = intermediateTransform; + bestAngle = intermediateAngle; + bestScore = intermediateScore; + bestDetails = intermediateDetails; + end + fineAngles = angleNeighborhood(bestAngle, ... + intermediateAngleStepDegrees, fineAngleStepDegrees); + [fineTransform, ~, fineScore, fineDetails] = bestCandidate( ... + fineAngles, fixedGray, movingTranslationImage, movingScoreImage, ... + fixedFeature, fixedScorePreview, fixedRows, fixedCols, sampleStep, ... + scoreRows, scoreCols, maximumTranslationFraction); + if fineScore >= bestScore bestTransform = fineTransform; bestScore = fineScore; + bestDetails = fineDetails; end if ~isfinite(bestScore) error("dic_preprocess:AutoAlignmentFailed", ... @@ -130,38 +167,66 @@ "angleDegrees", atan2d(transform(1, 2), transform(1, 1)), ... "translationX", transform(3, 1), ... "translationY", transform(3, 2), ... - "score", bestScore); + "score", bestScore, ... + "overlapFraction", bestDetails.overlapFraction, ... + "scoreMargin", bestDetails.scoreMargin, ... + "translationPeakMargin", bestDetails.translationPeakMargin); end -function [bestTransform, bestAngle, bestScore] = bestCandidate( ... - angles, fixedGray, movingGray, fixedFeature, ... - fixedRows, fixedCols, sampleStep, scoreRows, scoreCols) +function angles = angleNeighborhood(centerAngle, radius, step) + angles = centerAngle + (-radius:step:radius); + angles = mod(angles + 180, 360) - 180; + angles = unique(angles, "stable"); +end + +function [bestTransform, bestAngle, bestScore, bestDetails] = bestCandidate( ... + angles, fixedGray, movingTranslationImage, movingScoreImage, ... + fixedFeature, fixedScorePreview, fixedRows, fixedCols, sampleStep, ... + scoreRows, scoreCols, maximumTranslationFraction) fixedCenter = ([size(fixedGray, 2), size(fixedGray, 1)] + 1) / 2; - movingCenter = ([size(movingGray, 2), size(movingGray, 1)] + 1) / 2; + movingCenter = ([size(movingScoreImage, 2), ... + size(movingScoreImage, 1)] + 1) / 2; bestScore = -inf; bestAngle = 0; bestTransform = eye(3); - for angle = angles + bestDetails = candidateDetails(); + candidateScores = -inf(size(angles)); + for angleIndex = 1:numel(angles) + angle = angles(angleIndex); radians = angle * pi / 180; rotation = [cos(radians) sin(radians); ... -sin(radians) cos(radians)]; centerTranslation = fixedCenter - movingCenter * rotation; centered = warpPreview( ... - movingGray, rotation, centerTranslation, fixedRows, fixedCols); - [rowShift, colShift] = estimateTranslation( ... - fixedFeature, registrationFeature(centered)); + movingTranslationImage, rotation, centerTranslation, ... + fixedRows, fixedCols); + [rowShift, colShift, translationPeakMargin] = estimateTranslation( ... + fixedFeature, registrationFeature(centered), ... + maximumTranslationFraction); translation = centerTranslation + ... sampleStep * [colShift rowShift]; warped = warpPreview( ... - movingGray, rotation, translation, scoreRows, scoreCols); - score = orientedAlignmentScore( ... - fixedGray(scoreRows, scoreCols), warped); + movingScoreImage, rotation, translation, scoreRows, scoreCols); + [score, overlapFraction] = orientedAlignmentScore( ... + fixedScorePreview, warped); + candidateScores(angleIndex) = score; if score > bestScore bestScore = score; bestAngle = angle; bestTransform = [rotation [0; 0]; translation 1]; + bestDetails.overlapFraction = overlapFraction; + bestDetails.translationPeakMargin = translationPeakMargin; end end + finiteScores = sort(candidateScores(isfinite(candidateScores)), "descend"); + if numel(finiteScores) >= 2 + bestDetails.scoreMargin = finiteScores(1) - finiteScores(2); + end +end + +function value = candidateDetails() + value = struct("overlapFraction", 0, "scoreMargin", 0, ... + "translationPeakMargin", 0); end function preview = warpPreview(imageData, rotation, translation, rows, cols) @@ -179,7 +244,8 @@ feature = hypot(horizontal, vertical); end -function [rowShift, colShift] = estimateTranslation(fixedFeature, movingFeature) +function [rowShift, colShift, peakMargin] = estimateTranslation( ... + fixedFeature, movingFeature, maximumTranslationFraction) fixedFeature = fixedFeature - finiteMean(fixedFeature); movingFeature = movingFeature - finiteMean(movingFeature); fixedFeature(~isfinite(fixedFeature)) = 0; @@ -199,14 +265,72 @@ rowValues(rowValues > size(correlation, 1) / 2) - size(correlation, 1); colValues(colValues > size(correlation, 2) / 2) = ... colValues(colValues > size(correlation, 2) / 2) - size(correlation, 2); - allowedRows = abs(rowValues) <= floor(.45 * size(fixedFeature, 1)); - allowedCols = abs(colValues) <= floor(.45 * size(fixedFeature, 2)); + allowedRows = abs(rowValues) <= ... + floor(maximumTranslationFraction * size(fixedFeature, 1)); + allowedCols = abs(colValues) <= ... + floor(maximumTranslationFraction * size(fixedFeature, 2)); correlation(~allowedRows, :) = -inf; correlation(:, ~allowedCols) = -inf; [~, idx] = max(correlation(:)); [peakRow, peakCol] = ind2sub(size(correlation), idx); - rowShift = rowValues(peakRow); - colShift = colValues(peakCol); + rowOffset = quadraticPeakOffset( ... + correlation, peakRow, peakCol, 1); + colOffset = quadraticPeakOffset( ... + correlation, peakRow, peakCol, 2); + rowShift = rowValues(peakRow) + rowOffset; + colShift = colValues(peakCol) + colOffset; + peakValue = correlation(peakRow, peakCol); + sidelobes = correlation; + rowWindow = max(1, peakRow - 2):min(size(correlation, 1), peakRow + 2); + colWindow = max(1, peakCol - 2):min(size(correlation, 2), peakCol + 2); + sidelobes(rowWindow, colWindow) = -inf; + secondPeak = max(sidelobes(:)); + if isfinite(secondPeak) + peakMargin = (peakValue - secondPeak) / max(abs(peakValue), eps); + else + peakMargin = 0; + end +end + +function offset = quadraticPeakOffset(values, row, col, dimension) + offset = 0; + if dimension == 1 + if row <= 1 || row >= size(values, 1) + return; + end + previous = values(row - 1, col); + center = values(row, col); + following = values(row + 1, col); + else + if col <= 1 || col >= size(values, 2) + return; + end + previous = values(row, col - 1); + center = values(row, col); + following = values(row, col + 1); + end + denominator = previous - 2 * center + following; + if all(isfinite([previous center following])) && denominator < -eps + offset = .5 * (previous - following) / denominator; + offset = min(.5, max(-.5, offset)); + end +end + +function filtered = antiAliasForStep(imageData, sampleStep) + filtered = double(imageData); + if sampleStep <= 1 + return; + end + kernelWidth = 2 * floor(sampleStep / 2) + 1; + kernel = ones(1, kernelWidth) / kernelWidth; + valid = isfinite(filtered); + filtered(~valid) = 0; + weights = conv2(conv2(double(valid), kernel, "same"), ... + kernel.', "same"); + filtered = conv2(conv2(filtered, kernel, "same"), ... + kernel.', "same"); + filtered = filtered ./ max(weights, eps); + filtered(weights == 0) = NaN; end function value = finiteMean(imageData) @@ -218,7 +342,7 @@ end end -function score = alignmentScore(fixedImage, movingImage) +function [score, overlapFraction] = alignmentScore(fixedImage, movingImage) valid = isfinite(fixedImage) & isfinite(movingImage); overlapFraction = nnz(valid) / numel(valid); if overlapFraction < .2 @@ -234,10 +358,50 @@ score = -inf; return; end - score = (fixedValues.' * movingValues) / denominator; + globalScore = (fixedValues.' * movingValues) / denominator; + tileScores = localCorrelationScores(fixedImage, movingImage, valid); + if isempty(tileScores) + robustScore = globalScore; + else + robustScore = median(tileScores); + end + score = .6 * globalScore + .4 * robustScore - ... + .1 * (1 - overlapFraction); end -function score = orientedAlignmentScore(fixedImage, movingImage) +function scores = localCorrelationScores(fixedImage, movingImage, valid) + tileCount = 4; + rowEdges = round(linspace(1, size(fixedImage, 1) + 1, tileCount + 1)); + colEdges = round(linspace(1, size(fixedImage, 2) + 1, tileCount + 1)); + scores = zeros(tileCount^2, 1); + scoreCount = 0; + for rowIndex = 1:tileCount + rows = rowEdges(rowIndex):rowEdges(rowIndex + 1) - 1; + for colIndex = 1:tileCount + cols = colEdges(colIndex):colEdges(colIndex + 1) - 1; + tileValid = valid(rows, cols); + if nnz(tileValid) < max(4, ceil(.5 * numel(tileValid))) + continue; + end + fixedTile = fixedImage(rows, cols); + movingTile = movingImage(rows, cols); + fixedValues = fixedTile(tileValid); + movingValues = movingTile(tileValid); + fixedValues = fixedValues - mean(fixedValues); + movingValues = movingValues - mean(movingValues); + denominator = norm(fixedValues) * norm(movingValues); + if denominator > eps + scoreCount = scoreCount + 1; + scores(scoreCount, 1) = ... + (fixedValues.' * movingValues) / denominator; + end + end + end + scores = scores(1:scoreCount); +end + +function [score, overlapFraction] = orientedAlignmentScore( ... + fixedImage, movingImage) fixedHorizontal = [diff(fixedImage, 1, 2), ... zeros(size(fixedImage, 1), 1)]; fixedVertical = [diff(fixedImage, 1, 1); ... @@ -246,8 +410,20 @@ zeros(size(movingImage, 1), 1)]; movingVertical = [diff(movingImage, 1, 1); ... zeros(1, size(movingImage, 2))]; - horizontalScore = alignmentScore( ... + [horizontalScore, horizontalOverlap] = alignmentScore( ... fixedHorizontal, movingHorizontal); - verticalScore = alignmentScore(fixedVertical, movingVertical); - score = mean([horizontalScore, verticalScore]); + [verticalScore, verticalOverlap] = alignmentScore( ... + fixedVertical, movingVertical); + [magnitudeScore, magnitudeOverlap] = alignmentScore( ... + hypot(fixedHorizontal, fixedVertical), ... + hypot(movingHorizontal, movingVertical)); + componentScores = [horizontalScore, verticalScore, magnitudeScore]; + componentScores = componentScores(isfinite(componentScores)); + if isempty(componentScores) + score = -inf; + else + score = mean(componentScores); + end + overlapFraction = min( ... + [horizontalOverlap, verticalOverlap, magnitudeOverlap]); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m index be4399095..8920e9a2e 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m @@ -35,10 +35,7 @@ otherwise request.topImage = cache.currentReferenceImage; request.topTitle = "Current reference"; - if previewValue == "Current moving image" - request.bottomImage = cache.currentMovingImage; - request.bottomTitle = previewValue; - elseif previewValue == "False-color overlay" && ... + if previewValue == "False-color overlay" && ... dic_preprocess.sourceFiles.hasImagePair(cache) request.bottomImage = dic_preprocess.analysisRun.makeFalseColorOverlay( ... cache.currentReferenceImage, cache.currentMovingImage); diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m index 0344d61a8..ce48d259e 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m @@ -2,7 +2,7 @@ function state = startPointMatching(state, ~) if dic_preprocess.sourceFiles.hasImagePair(state.session.cache) state.session.workflow.mode = "matching"; - state.project.parameters.previewMode = "Current moving image"; + state.project.parameters.previewMode = "Current pair"; state.project.annotations.matchReferencePoints = zeros(0,2); state.project.annotations.matchMovingPoints = zeros(0,2); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m index 7b6382e6f..18494d552 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m @@ -16,8 +16,8 @@ OnSelectionChanged=@dic_preprocess.sourceFiles.sourceChanged, ... SourceRole="movingImage", SourceIdPrefix="moving", Required=true), ... labkit.app.layout.field("previewMode", Label="Preview:", ... - Kind="choice", Choices=["Current pair", "Current moving image", ... - "False-color overlay", "Original pair", "ROI mask"], ... + Kind="choice", Choices=["Current pair", "False-color overlay", ... + "Original pair", "ROI mask"], ... Bind="project.parameters.previewMode", ... OnValueChanged=@dic_preprocess.analysisRun.changePreviewMode)}); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m b/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m index 7820d665b..5a2ed2471 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m @@ -2,8 +2,9 @@ % Expected caller: dic_preprocess.definition. Output owns the current payload % version, creation defaults, and validation. Side effects are none. function spec = projectSpec() - spec = labkit.app.project.Schema(Version=1, Create=@createProject, ... - Validate=@validateProject, SourceBindings="inputs.sources"); + spec = labkit.app.project.Schema(Version=2, Create=@createProject, ... + Validate=@validateProject, Migrate=@migrateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() @@ -38,9 +39,30 @@ {'previewMode', 'maskBoundaryStyle'})), ... 'dic_preprocess:InvalidProject', ... 'DIC preprocess project parameters are incomplete.'); + previewMode = string(project.parameters.previewMode); + assert(isscalar(previewMode) && ~ismissing(previewMode) && ... + any(previewMode == ["Current pair", "False-color overlay", ... + "Original pair", "ROI mask"]), ... + 'dic_preprocess:InvalidProject', ... + 'DIC preprocess preview mode is unsupported.'); accepted = true; end +function project = migrateProject(project, fromVersion) + if double(fromVersion) ~= 1 + error('dic_preprocess:UnsupportedProjectMigration', ... + 'DIC Preprocess cannot migrate project version %d.', fromVersion); + end + if isfield(project, "parameters") && ... + isfield(project.parameters, "previewMode") + previewMode = string(project.parameters.previewMode); + if isscalar(previewMode) && ... + previewMode == "Current moving image" + project.parameters.previewMode = "Current pair"; + end + end +end + function history = emptyEditHistory() history = struct('editSteps', {}, 'maskImage', {}, ... 'maskPoints', {}, 'description', {}); diff --git a/docs/apps/dic/dic-preprocess/README.md b/docs/apps/dic/dic-preprocess/README.md index 7ac2605d7..941ca7846 100644 --- a/docs/apps/dic/dic-preprocess/README.md +++ b/docs/apps/dic/dic-preprocess/README.md @@ -45,7 +45,6 @@ derived working pair and replays no edits. | Mode | Display | | --- | --- | | Current pair | current reference above the current moving image | -| Current moving image | moving image in the main comparison view | | False-color overlay | red/green registration comparison of the current pair | | Original pair | source images before applied edits | | ROI mask | current binary mask over the image domain | @@ -78,16 +77,18 @@ clustered points provide weak rotational leverage. ## Automatic Alignment **Auto align current pair** runs the app-owned base-MATLAB rigid-registration -path. It searches rotations from -30 to +30 degrees, estimates translation -with zero-padded amplitude-weighted phase correlation, and ranks candidate -angles using oriented structure on a finer preview so subsampled DIC texture -cannot dominate the decision through aliasing. Its diagnostic event records -the accepted angle, translation, and structural score. It returns the same +path. It searches the complete rotation circle in global, one-degree, and +quarter-degree stages; translation candidates may span up to 75% of either +preview dimension. It estimates translation with anti-aliased, zero-padded +amplitude-weighted phase correlation and subpixel peak refinement, then ranks candidate angles using robust, +overlap-aware oriented structure on a finer preview. Its diagnostic event +records the accepted angle, translation, overlap, structural score, score +margin, and translation-peak margin. It returns the same aligned image and rigid-transform fields as manual alignment. Automatic alignment remains a starting estimate, not a guarantee of DIC-quality correspondence. Always inspect the false-color overlay and prefer manual points -when rotation exceeds the search range or the image has repeated texture, -large occlusion, scale change, deformation, or weak contrast. +when the image has repeated texture, extremely small overlap, scale change, +deformation, large occlusion, or weak contrast. ## Crop ROI diff --git a/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md b/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md index e6c211ed4..cfdacd853 100644 --- a/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md +++ b/docs/history/records/2026/08/LK-20260803-dic-preprocess-interaction-registration-repair.md @@ -28,8 +28,14 @@ use the plot revision contract when the crop changes the image domain. Restore automatic rigid behavior with a bounded coarse-to-fine rotation search and amplitude-weighted, zero-padded phase correlation implemented in base MATLAB. Score candidate transforms at a finer structural resolution so DIC texture -aliasing cannot select a plausible but wrong angle. Keep paired matching as a -point interaction with no implied path between anchors. +aliasing cannot select a plausible but wrong angle. Use anti-aliased previews, +subpixel peak refinement, robust local scoring, and explicit overlap and +ambiguity diagnostics to tolerate outliers and partial occlusion. Keep paired +matching as a point interaction with no implied path between anchors, and +remove the duplicate moving-image preview choice while migrating its saved +value to the identical current-pair view. Extend the global search over the +complete rotation circle and permit translations up to 75% of a preview axis, +then retain fine angular resolution through two refinement stages. ## Changes @@ -39,10 +45,13 @@ point interaction with no implied path between anchors. - Mask boundaries use a variable closed anchor path and can enter edit mode from an empty boundary. - Paired match anchors render only numbered points and never a connecting line. +- The redundant Current moving image selector is removed; version-1 projects + migrate that value to Current pair without changing displayed image data. - Automatic alignment estimates translation on a response-limited preview, - scores rotation candidates with finer oriented structure, applies the - accepted transform at source resolution, and records its numeric decision - details in the diagnostic event. + suppresses sampling aliasing and isolated intensity outliers, refines the + correlation peak to subpixel precision, scores rotation candidates with + robust overlap-aware oriented structure, applies the accepted transform at + source resolution, and records numeric score and ambiguity details. ## User and data impact @@ -54,16 +63,19 @@ saved images, masks, coordinate conventions, and export schemas are unchanged. ## Compatibility and migration -The change is compatible with version-1 DIC Preprocess projects. No project -migration is required. Manual alignment remains a rigid rotation-and- -translation fit, and automatic alignment continues to return the same -three-by-three row-vector transform shape. +The change is compatible with version-1 DIC Preprocess projects. The project +schema migrates the retired Current moving image value to Current pair; no +image, annotation, edit, result, or coordinate data changes. Manual alignment +remains a rigid rotation-and-translation fit, and automatic alignment +continues to return the same three-by-three row-vector transform shape. ## Validation Focused scientific evidence covers existing integer translation, a controlled -rotation-plus-translation case, and finite transform-quality details without -optional Toolboxes. The hidden-GUI workflow covers moving-preview selection, +rotation-plus-translation case, subpixel motion with an isolated outlier and +partial occlusion, a 112-degree rotation with translation beyond the previous +limit, and finite transform-quality details without optional Toolboxes. The +hidden-GUI workflow covers current-pair matching, point-only paired anchors, numeric automatic-registration diagnostics, crop overlays on both axes, fitted crop limits, successful mask activation, export, and project restore. The supplied state/source pair confirmed the previous @@ -74,13 +86,15 @@ finer structural scoring recovered the same rotation neighborhood. - `labkittest.run(Owner="apps/dic/dic_preprocess/analysisrun", Contract="scientific")` - `labkittest.run(Owner="apps/dic/dic_preprocess/workbench", Contract="presentation")` +- Final focused evidence passed 7 scientific, 3 persistence/result/presentation, + and 1 hidden-GUI workflow identities. - The redacted diagnostic bundle reported `labkit:app:runtime:InvalidPointSlotsValue` from mask activation. ## Known limitations and follow-up -Automatic alignment searches rotations from -30 to +30 degrees and does not -estimate scale, shear, or deformation. Repeated texture, weak contrast, large -occlusion, or limited overlap can still require manual matched points. Native +Automatic alignment does not estimate scale, shear, perspective, or +deformation. Repeated texture, weak contrast, large occlusion, or very limited +overlap can still require manual matched points. Native pointer feel and suitability for real DIC imagery remain manual review boundaries. diff --git a/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m b/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m index 1b96cc3e8..da7304967 100644 --- a/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m +++ b/tests/specs/apps/dic/dic_preprocess/analysisRun/DicPreprocessScientificSpec.m @@ -47,8 +47,9 @@ function alignsIntegerTranslationsWithoutAnOptionalToolbox(testCase) [aligned, transform, method] = ... dic_preprocess.analysisRun.autoAlignMovingToReference(reference, moving); - testCase.verifyEqual(aligned, reference); - testCase.verifyEqual(transform, [1 0 0; 0 1 0; 3 -2 1]); + testCase.verifyEqual(aligned, reference, AbsTol=.002); + testCase.verifyEqual(transform, ... + [1 0 0; 0 1 0; 3 -2 1], AbsTol=.002); testCase.verifySubstring(method, 'toolbox-free'); end @@ -82,6 +83,65 @@ function reducesControlledRotationAndTranslationWithoutAToolbox(testCase) testCase.verifyTrue(isfinite(quality.score)); end + function recoversSubpixelMotionWithOutliersAndPartialOcclusion(testCase) + [x, y] = meshgrid(1:144, 1:128); + reference = .35 * sin(x / 3.8) .* cos(y / 5.2) + ... + .24 * sin((x + 2 * y) / 9.1) + ... + exp(-((x - 42).^2 + (y - 31).^2) / 120) + ... + .7 * exp(-((x - 106).^2 + (y - 89).^2) / 85); + angle = 5.5 * pi / 180; + rotation = [cos(angle) sin(angle); -sin(angle) cos(angle)]; + center = ([size(reference, 2), size(reference, 1)] + 1) / 2; + expected = [rotation [0; 0]; ... + center - center * rotation + [2.4 -1.7], 1]; + moving = dic_preprocess.analysisRun.applyRigidTransform( ... + reference, reference, inv(expected)); + moving(18:43, 102:132) = median(moving(:)); + moving(7, 9) = 100 * max(abs(moving(:))); + + [aligned, transform, ~, quality] = ... + dic_preprocess.analysisRun.autoAlignMovingToReference( ... + reference, moving); + + testCase.verifyLessThan(norm(reference - aligned, "fro"), ... + .7 * norm(reference - moving, "fro")); + testCase.verifyEqual(quality.angleDegrees, 5.5, AbsTol=.75); + testCase.verifyLessThan(norm(transform(3, 1:2) - ... + expected(3, 1:2)), 2.5); + testCase.verifyGreaterThanOrEqual(quality.overlapFraction, .2); + testCase.verifyLessThanOrEqual(quality.overlapFraction, 1); + testCase.verifyTrue(all(isfinite([quality.scoreMargin, ... + quality.translationPeakMargin]))); + end + + function recoversLargeRotationAndTranslation(testCase) + [x, y] = meshgrid(1:176, 1:160); + reference = .28 * sin(x / 4.1) .* cos(y / 6.3) + ... + .19 * cos((2 * x - y) / 10.7) + ... + 1.2 * exp(-((x - 39).^2 + (y - 45).^2) / 95) + ... + .8 * exp(-((x - 137).^2 + (y - 112).^2) / 130); + angleDegrees = 112; + angle = angleDegrees * pi / 180; + rotation = [cos(angle) sin(angle); -sin(angle) cos(angle)]; + center = ([size(reference, 2), size(reference, 1)] + 1) / 2; + expected = [rotation [0; 0]; ... + center - center * rotation + [82 -18], 1]; + moving = dic_preprocess.analysisRun.applyRigidTransform( ... + reference, reference, inv(expected)); + + [aligned, transform, ~, quality] = ... + dic_preprocess.analysisRun.autoAlignMovingToReference( ... + reference, moving); + + testCase.verifyLessThan(norm(reference - aligned, "fro"), ... + .75 * norm(reference - moving, "fro")); + testCase.verifyEqual(quality.angleDegrees, ... + angleDegrees, AbsTol=1.5); + testCase.verifyLessThan(norm(transform(3, 1:2) - ... + expected(3, 1:2)), 6); + testCase.verifyGreaterThanOrEqual(quality.overlapFraction, .2); + end + function rejectsPairsWithoutFiniteRegistrationStructure(testCase) testCase.verifyError(@() ... dic_preprocess.analysisRun.autoAlignMovingToReference( ... diff --git a/tests/specs/apps/dic/dic_preprocess/project/DicPreprocessProjectSpec.m b/tests/specs/apps/dic/dic_preprocess/project/DicPreprocessProjectSpec.m index 50053eba3..aa61a9f1f 100644 --- a/tests/specs/apps/dic/dic_preprocess/project/DicPreprocessProjectSpec.m +++ b/tests/specs/apps/dic/dic_preprocess/project/DicPreprocessProjectSpec.m @@ -2,14 +2,20 @@ %DICPREPROCESSPROJECTSPEC Specify durable DIC preprocess project schema. methods (Test, TestTags = {'Contract:persistence', 'Env:headless'}) - function createsAValidProjectWithoutDurableDecodedPixels(testCase) + function createsAValidProjectAndMigratesTheRedundantPreview(testCase) spec = dic_preprocess.projectSpec(); project = spec.Create(); project.inputs.sources = sourceRecord("referenceImage", "reference.png"); + legacy = project; + legacy.parameters.previewMode = "Current moving image"; + migrated = spec.Migrate(legacy, 1); testCase.verifyTrue(spec.Validate(project)); + testCase.verifyTrue(spec.Validate(migrated)); + testCase.verifyEqual(migrated.parameters.previewMode, "Current pair"); testCase.verifyFalse(isfield(project.inputs, 'referenceImage')); - testCase.verifyEmpty(spec.Migrate); + testCase.verifyError(@() spec.Migrate(project, 0), ... + "dic_preprocess:UnsupportedProjectMigration"); end end end diff --git a/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m b/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m index 68fd87f81..ab796cd24 100644 --- a/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m +++ b/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m @@ -23,7 +23,7 @@ function alignsCropsExportsAndRestoresASyntheticPair(testCase) runtime.invokeAction("startPointMatching"); testCase.verifyEqual( ... runtime.State.project.parameters.previewMode, ... - "Current moving image"); + "Current pair"); runtime.applyInteraction("matchPoints", ... "interactionChanged", ... {[20 15; 30 24], [17 17; 27 26]}); @@ -37,7 +37,8 @@ function alignsCropsExportsAndRestoresASyntheticPair(testCase) "dic_preprocess.analysisrun.runautomaticregistration.status"); testCase.verifyNumElements(event, 1); testCase.verifyEqual(sort(string(fieldnames(event.attributes))), ... - sort(["angleDegrees"; "score"; ... + sort(["angleDegrees"; "overlapFraction"; "score"; ... + "scoreMargin"; "translationPeakMargin"; ... "translationX"; "translationY"])); runtime.invokeAction("startCropRoi"); overlayTag = "labkitDicPreprocessPreviewOverlay"; From ec969202f2654b56d451d4637618099901ddd7ee Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 15:29:22 -0500 Subject: [PATCH 13/32] fix: highlight session log rows --- +labkit/+app/+internal/SessionLogViewer.m | 3 +++ docs/framework/README.md | 5 +++-- .../LK-20260803-app-sdk-diagnostics-and-input-workflows.md | 6 ++++-- tests/specs/labkit/app/SessionLogViewerSpec.m | 2 ++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/+labkit/+app/+internal/SessionLogViewer.m b/+labkit/+app/+internal/SessionLogViewer.m index 45ca7ad6b..ff7a4f8ac 100644 --- a/+labkit/+app/+internal/SessionLogViewer.m +++ b/+labkit/+app/+internal/SessionLogViewer.m @@ -198,6 +198,9 @@ function createFigure(obj) CellSelectionCallback=@(~, event) ... obj.selectRow(event), ... Tag="labkitSessionLogTable"); + if isprop(obj.EventTable, "SelectionType") + obj.EventTable.SelectionType = "row"; + end obj.EventTable.Layout.Row = 4; obj.DetailArea = uitextarea(root, ... diff --git a/docs/framework/README.md b/docs/framework/README.md index 364268464..078bf5070 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -167,8 +167,9 @@ utilities do not compete with the App's workflow controls: a redacted log or a complete sensitive log containing current App state; redacted is the default. Runtime collection, the Session Log, and the local journal retain complete messages, attributes, exception text, and stack - locations. Privacy filtering begins only after the user selects redacted - export; a text fallback preserves the selected redacted or complete mode. + locations; selecting an event highlights its complete table row. Privacy + filtering begins only after the user selects redacted export; a text fallback + preserves the selected redacted or complete mode. These actions are framework-owned native behavior. Apps do not declare menu items, implement clipboard integration, or duplicate project persistence diff --git a/docs/history/records/2026/08/LK-20260803-app-sdk-diagnostics-and-input-workflows.md b/docs/history/records/2026/08/LK-20260803-app-sdk-diagnostics-and-input-workflows.md index 5616f9da4..97b4bd21f 100644 --- a/docs/history/records/2026/08/LK-20260803-app-sdk-diagnostics-and-input-workflows.md +++ b/docs/history/records/2026/08/LK-20260803-app-sdk-diagnostics-and-input-workflows.md @@ -52,6 +52,8 @@ wording they handle directly, and scientific interaction semantics. pause-follow controls. - Each viewer title names its owning App, and manual TRACE capture lives in the viewer instead of the App Tools menu. +- Selecting an event in the viewer highlights its complete row while retaining + the same structured-detail inspection behavior. - ERROR and CRITICAL records enable TRACE capture for later activity; trace records now distinguish state update, validation, presentation, native commit, and rollback cleanup stages. @@ -99,8 +101,8 @@ activation, distinct trace stages, generated ZIP and fallback names, redacted default export, full-detail retention, explicit state-inclusive export, and privacy-mode-preserving fallback. Hidden-GUI specifications cover App-specific titles, the single level selector, viewer-local TRACE control, continuous -follow, complete event inspection, exports from both entry points, and -automatic screenshot/project-state artifacts. App SDK source +follow, full-row event selection, complete event inspection, exports from both +entry points, and automatic screenshot/project-state artifacts. App SDK source evidence also covers file-predicate masks, aggregate notices, preserved source alignment, native failure alerts, and point-only paired anchors. diff --git a/tests/specs/labkit/app/SessionLogViewerSpec.m b/tests/specs/labkit/app/SessionLogViewerSpec.m index 78a480e50..bf8ab600b 100644 --- a/tests/specs/labkit/app/SessionLogViewerSpec.m +++ b/tests/specs/labkit/app/SessionLogViewerSpec.m @@ -72,6 +72,8 @@ function inspectsEarlierDebugFiltersLongMessagesAndClearsOnlyView(testCase) testCase.verifyEqual( ... string(tableHandle.Data.Properties.VariableNames), ... ["Time", "Level", "Area", "Message"]); + testCase.verifyEqual( ... + string(tableHandle.SelectionType), "row"); testCase.verifyEmpty(findall( ... viewerFigure, "Tag", "labkitSessionLogAudience")); testCase.verifyEmpty(findall( ... From 2a8565c370406573a275abab0efcaa2f35fbfe3b Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 3 Aug 2026 15:57:26 -0500 Subject: [PATCH 14/32] fix: persist marker edits and repair gait preview --- +labkit/+app/+layout/plotArea.m | 9 ++ .../+analysisRun/optionsChanged.m | 2 + .../+analysisRun/runFromWorkbench.m | 2 + .../+gait_analysis/+gaitPreview/draw.m | 27 ++++-- .../+gait_analysis/+gaitPreview/layoutArea.m | 24 ++++-- .../+gait_analysis/+gaitPreview/present.m | 8 +- .../+gait_analysis/+sourceFiles/adoptPose.m | 2 + .../+gait_analysis/+stepPreview/next.m | 8 +- .../+gait_analysis/+stepPreview/previous.m | 8 +- .../+gait_analysis/+stepPreview/select.m | 8 +- .../+gait_analysis/+workbench/buildLayout.m | 9 +- .../+gait_analysis/+workbench/present.m | 3 +- .../+gait_analysis/createSession.m | 3 +- .../gait_analysis/+gait_analysis/definition.m | 2 +- .../+frameNavigation/changeFrame.m | 4 + .../+markerEditing/changePoints.m | 2 +- .../+video_marker/+markerEditing/clear.m | 3 +- .../+video_marker/+markerEditing/setPoints.m | 6 +- .../+video_marker/+markerEditing/undo.m | 2 +- .../+resultFiles/importMarkers.m | 1 + .../video_marker/+video_marker/definition.m | 4 +- docs/apps/gait/gait-analysis/README.md | 11 ++- .../image-measurement/video-marker/README.md | 23 +++-- docs/framework/guides/runtime.md | 18 ++++ ...app-sdk-diagnostics-and-input-workflows.md | 6 ++ ...-video-marker-autosave-and-gait-preview.md | 83 +++++++++++++++++++ .../gaitPreview/FourPanelPreviewSpec.m | 70 ++++++++++++++++ .../workbench/GaitWorkflowSpec.m | 51 +++++++++++- .../markerEditing/PointChangeAutosaveSpec.m | 54 ++++++++++++ .../workbench/VideoMarkerWorkflowSpec.m | 14 ++++ 30 files changed, 418 insertions(+), 49 deletions(-) create mode 100644 docs/history/records/2026/08/LK-20260803-video-marker-autosave-and-gait-preview.md create mode 100644 tests/specs/apps/gait/gait_analysis/gaitPreview/FourPanelPreviewSpec.m create mode 100644 tests/specs/apps/image_measurement/video_marker/markerEditing/PointChangeAutosaveSpec.m diff --git a/+labkit/+app/+layout/plotArea.m b/+labkit/+app/+layout/plotArea.m index 9a8977b92..089abc464 100644 --- a/+labkit/+app/+layout/plotArea.m +++ b/+labkit/+app/+layout/plotArea.m @@ -43,6 +43,15 @@ % node = labkit.app.layout.plotArea("preview", @drawTrace, ... % AxisIds="trace"); % +% top = labkit.app.layout.plotArea("top", @drawTop, ... +% Layout="pair", AxisIds=["left" "right"]); +% bottom = labkit.app.layout.plotArea("bottom", @drawBottom, ... +% Layout="pair", AxisIds=["summary" "scale"], ... +% ColumnWidths={'1x', 90}); +% workspace = labkit.app.layout.workspace(Title="Four plots"); +% workspace = workspace.page("plots", "Plots", {top, bottom}); +% workspace = workspace.initialPage("plots"); +% % See also labkit.app.view.Snapshot, labkit.app.layout.workspace, % labkit.app.interaction.anchorPath node = labkit.app.internal.LayoutNode.plotArea(id, renderer, varargin{:}); diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m index ac5ee01b1..d621cfd8b 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m @@ -10,4 +10,6 @@ applicationState.project.results.lastExport = []; applicationState.session.cache.lastRunFingerprint = ""; applicationState.session.selection.currentStepIndex = 1; +applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; end diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m index e77e38b03..5c3716b73 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m @@ -36,6 +36,8 @@ state.project.results.lastExport = []; state.session.cache.lastRunFingerprint = task.fingerprint; state.session.selection.currentStepIndex = 1; +state.session.cache.plotViewRevision = ... + state.session.cache.plotViewRevision + 1; context.log("info", "gait_analysis.analysisrun.runfromworkbench.status", sprintf("Gait analysis complete: %d valid step(s).", ... sum(result.stepTable.is_valid))); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m index 7d469fcfc..932bb464b 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m +++ b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m @@ -1,9 +1,11 @@ % Expected caller: Gait Analysis plot-area renderer. Inputs are axes by ID % and a pure gait preview model. Side effects are limited to those axes. function draw(axesById, model) -drawOne(axesById.skeleton, withKind(model, "skeleton")); -drawOne(axesById.angles, withKind(model, "angles")); -drawOne(axesById.segments, withKind(model, "segments")); +axisIds = string(fieldnames(axesById)); +for k = 1:numel(axisIds) + axisId = axisIds(k); + drawOne(axesById.(char(axisId)), withKind(model, axisId)); +end end function model = withKind(model, kind) @@ -17,7 +19,9 @@ function drawOne(ax, model) labkit.app.plot.showMessage(ax, ... "Load pose data to preview gait analysis."); elseif model.kind == "skeleton" - drawSkeletons(ax, model); + drawSkeletons(ax, model, false); + elseif model.kind == "overview" + drawSkeletons(ax, model, true); elseif ~model.result.ok ax.YDir = "normal"; labkit.app.plot.showMessage(ax, ... @@ -34,11 +38,12 @@ function drawOne(ax, model) disableHitTesting(ax); end -function drawSkeletons(ax, model) +function drawSkeletons(ax, model, showFullRecording) pose = model.pose; frames = 1:size(pose.coords, 1); titleText = "All overlaid skeleton trajectories"; - if model.result.ok && ~isempty(model.result.stepTable) + if ~showFullRecording && model.result.ok && ... + ~isempty(model.result.stepTable) row = model.result.stepTable(model.selectedStep, :); frames = row.lift_off_frame:row.landing_frame; titleText = sprintf("Step %d | frames %d-%d", ... @@ -54,7 +59,9 @@ function drawSkeletons(ax, model) pose.coords(frames, second, 1), NaN(numel(frames), 1)].'; y = [pose.coords(frames, first, 2), ... pose.coords(frames, second, 2), NaN(numel(frames), 1)].'; - plot(ax, x(:), y(:), "-", "Color", [0.55 0.55 0.55]); + plot(ax, x(:), y(:), "-", ... + "Color", [0.55 0.55 0.55], ... + "HandleVisibility", "off"); end for k = 1:numel(pose.pointNames) plot(ax, pose.coords(frames, k, 1), pose.coords(frames, k, 2), ... @@ -67,7 +74,9 @@ function drawSkeletons(ax, model) ylabel(ax, "Pixel Y"); grid(ax, "on"); legend(ax, "Location", "best"); - if model.result.ok && ~isempty(model.result.stepTable) + labkit.app.plot.fitAxesToGraphics(ax, EqualDataUnits=true); + if ~showFullRecording && model.result.ok && ... + ~isempty(model.result.stepTable) addStepAnnotation(ax, model.result.stepTable(model.selectedStep, :)); end end @@ -91,6 +100,7 @@ function drawAngles(ax, model) ylabel(ax, "Angle (deg)"); grid(ax, "on"); legend(ax, "Location", "best"); + labkit.app.plot.fitAxesToGraphics(ax); end function drawSegments(ax, model) @@ -114,6 +124,7 @@ function drawSegments(ax, model) ylabel(ax, "Length (" + unit + ")"); grid(ax, "on"); legend(ax, "Location", "best"); + labkit.app.plot.fitAxesToGraphics(ax); end function value = selectedFrames(model) diff --git a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m index 302184cb6..2f48c17d9 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m +++ b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m @@ -1,11 +1,17 @@ % App-owned implementation for gait_analysis.gaitPreview.layoutArea within the gait_analysis product workflow. -function area = layoutArea() -%LAYOUTAREA Declare the three stacked Gait Preview axes. -area = labkit.app.layout.plotArea("gaitAxes", ... - @gait_analysis.gaitPreview.draw, ... - Title="Gait Preview", Layout="stack", ... - AxisIds=["skeleton", "angles", "segments"], ... - AxisTitles=["Skeleton trajectories", ... - "Joint angles", "Segment lengths"], ... - ScrollZoomAxes=["xy", "x", "x"]); +function areas = layoutArea() +%LAYOUTAREA Declare two paired rows forming the 2-by-2 Gait Preview. +areas = { ... + labkit.app.layout.plotArea("gaitStepAxes", ... + @gait_analysis.gaitPreview.draw, ... + Title="Selected Step", Layout="pair", ... + AxisIds=["skeleton", "angles"], ... + AxisTitles=["Skeleton trajectories", "Joint angles"], ... + ScrollZoomAxes=["xy", "x"]), ... + labkit.app.layout.plotArea("gaitContextAxes", ... + @gait_analysis.gaitPreview.draw, ... + Title="Lengths and Full Recording", Layout="pair", ... + AxisIds=["segments", "overview"], ... + AxisTitles=["Segment lengths", "Full gait overlay"], ... + ScrollZoomAxes=["x", "xy"])}; end diff --git a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m index be6d00876..a2b91acf8 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m +++ b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m @@ -1,5 +1,7 @@ % App-owned implementation for gait_analysis.gaitPreview.present within the gait_analysis product workflow. -function view = present(model) -%PRESENT Supply the current model to the Gait Preview renderer. -view = labkit.app.view.Snapshot().renderPlot("gaitAxes", model); +function view = present(model, viewRevision) +%PRESENT Supply one model and viewport revision to both preview rows. +view = labkit.app.view.Snapshot() ... + .renderPlot("gaitStepAxes", model, ViewRevision=viewRevision) ... + .renderPlot("gaitContextAxes", model, ViewRevision=viewRevision); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m index 01b61181f..a17e50867 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m +++ b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m @@ -13,6 +13,8 @@ applicationState.project.results.lastExport = []; applicationState.session.cache.lastRunFingerprint = ""; applicationState.session.selection.currentStepIndex = 1; +applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; if ~pose.ok || isempty(selection.Indices) return end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m index ef65ea695..d9e39af33 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m @@ -1,7 +1,13 @@ % App-owned implementation for gait_analysis.stepPreview.next within the gait_analysis product workflow. function applicationState = next(applicationState, ~) %NEXT Select the following detected gait step. -applicationState.session.selection.currentStepIndex = ... +previous = applicationState.session.selection.currentStepIndex; +selected = ... gait_analysis.stepPreview.boundedIndex(applicationState, ... applicationState.session.selection.currentStepIndex + 1); +applicationState.session.selection.currentStepIndex = selected; +if selected ~= previous + applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; +end end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m index df944a4e2..e15f4d1a8 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m @@ -1,7 +1,13 @@ % App-owned implementation for gait_analysis.stepPreview.previous within the gait_analysis product workflow. function applicationState = previous(applicationState, ~) %PREVIOUS Select the preceding detected gait step. -applicationState.session.selection.currentStepIndex = ... +previous = applicationState.session.selection.currentStepIndex; +selected = ... gait_analysis.stepPreview.boundedIndex(applicationState, ... applicationState.session.selection.currentStepIndex - 1); +applicationState.session.selection.currentStepIndex = selected; +if selected ~= previous + applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; +end end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m index b199d1ab7..bd309f192 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m @@ -10,7 +10,13 @@ if isempty(selection.CellIndices) return end -applicationState.session.selection.currentStepIndex = ... +previous = applicationState.session.selection.currentStepIndex; +selected = ... gait_analysis.stepPreview.boundedIndex( ... applicationState, selection.CellIndices(1, 1)); +applicationState.session.selection.currentStepIndex = selected; +if selected ~= previous + applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; +end end diff --git a/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m b/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m index 286d66671..ecc144a64 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m +++ b/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m @@ -12,15 +12,18 @@ "results", "Results + Export", ... [review, {gait_analysis.resultFiles.layoutSection()}]); preview = gait_analysis.gaitPreview.layoutArea(); +workspace = labkit.app.layout.workspace(Title="Gait Preview"); +workspace = workspace.page( ... + "gaitPreview", "Gait Preview", preview); +workspace = workspace.initialPage("gaitPreview"); usage = [ ... "1. Open a current Video Marker project or autosave MAT.", ... "2. Embedded frame rate, skeleton, calibration, and annotations are the analysis source.", ... "3. Loading immediately shows all overlaid skeleton trajectories.", ... - "4. Run analysis, then select one step to review its skeleton, angles, lengths, and translations.", ... + "4. Run analysis, then review one step's skeleton, angles, and lengths beside the full-recording overlay.", ... "5. Export coordinates include raw pixel columns plus optional scaled/origin-shifted columns."]; layout = labkit.app.layout.workbench( ... {source, options, results}, ... - Workspace=labkit.app.layout.workspace( ... - preview, Title="Gait Preview"), ... + Workspace=workspace, ... UsageTitle="Workflow Notes", Usage=usage); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+workbench/present.m b/apps/gait/gait_analysis/+gait_analysis/+workbench/present.m index 21a52062f..abf5ab83b 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+workbench/present.m +++ b/apps/gait/gait_analysis/+gait_analysis/+workbench/present.m @@ -16,5 +16,6 @@ .include(gait_analysis.stepPreview.present(result, selectedStep)) ... .include(gait_analysis.resultFiles.present( ... applicationState.session.workflow.outputFolder, result.ok)) ... - .include(gait_analysis.gaitPreview.present(model)); + .include(gait_analysis.gaitPreview.present( ... + model, applicationState.session.cache.plotViewRevision)); end diff --git a/apps/gait/gait_analysis/+gait_analysis/createSession.m b/apps/gait/gait_analysis/+gait_analysis/createSession.m index 43a9424ae..b659098a7 100644 --- a/apps/gait/gait_analysis/+gait_analysis/createSession.m +++ b/apps/gait/gait_analysis/+gait_analysis/createSession.m @@ -26,6 +26,7 @@ selection = labkit.app.event.ListSelection(Indices=1:min(1, numel(paths))); session = struct("selection", struct("files", selection, ... "currentStepIndex", 1), "cache", struct("filepath", filepath, ... - "pose", pose, "lastRunFingerprint", fingerprint), ... + "pose", pose, "lastRunFingerprint", fingerprint, ... + "plotViewRevision", 0), ... "workflow", struct("outputFolder", outputFolder)); end diff --git a/apps/gait/gait_analysis/+gait_analysis/definition.m b/apps/gait/gait_analysis/+gait_analysis/definition.m index 0474bcf5e..dfa25a4e6 100644 --- a/apps/gait/gait_analysis/+gait_analysis/definition.m +++ b/apps/gait/gait_analysis/+gait_analysis/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_GaitAnalysis_app", AppId="gait_analysis", ... Title="Gait Analysis", DisplayName="Gait Analysis", Family="Gait", ... - AppVersion="2.2.1", Updated="2026-07-30", ... + AppVersion="2.2.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=gait_analysis.projectSpec(), CreateSession=@gait_analysis.createSession, ... Workbench=gait_analysis.workbench.buildLayout(), ... diff --git a/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m index 5bf6c158d..7baae630b 100644 --- a/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m +++ b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m @@ -17,6 +17,7 @@ return end try + previousFrames = state.project.annotations.frames; resource = context.getResource("document", "video"); if ~isstruct(resource) || ~isscalar(resource) || ... ~isfield(resource, "path") || resource.path ~= paths(1) @@ -50,6 +51,9 @@ state.session.workflow.scaleReferenceEditing = false; state.session.view.scaleBar = []; state = video_marker.resultFiles.clearExportState(state); +if ~isequaln(previousFrames, frames) + state = video_marker.sessionControl.saveAutosave(state, context); +end if report.predictedFrames > 0 context.log("info", "video_marker.framenavigation.changeframe.predicted", ... "Predicted " + string(report.predictedFrames) + ... diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m index 0043f2fc9..935a46b64 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m @@ -17,7 +17,7 @@ total = numel(state.project.annotations.skeleton.pointIds); points = points(1:min(size(points, 1), total), :); frame = state.session.cache.frameIndex; -state = video_marker.markerEditing.setPoints(state, points); +state = video_marker.markerEditing.setPoints(state, points, context); context.log("info", "video_marker.markerediting.changepoints.status", ... "Frame " + string(frame) + " points: " + ... string(size(points, 1)) + " / " + string(total) + "."); diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m index 35c1c8617..5be801744 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m @@ -5,7 +5,8 @@ return end frame = state.session.cache.frameIndex; -state = video_marker.markerEditing.setPoints(state, zeros(0, 2)); +state = video_marker.markerEditing.setPoints( ... + state, zeros(0, 2), context); context.log("info", "video_marker.markerediting.clear.status", ... "Cleared frame " + string(frame) + " points."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/setPoints.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/setPoints.m index d8aee39f5..7aa6c4765 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/setPoints.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/setPoints.m @@ -1,6 +1,6 @@ % App-owned implementation for video_marker.markerEditing.setPoints within the video_marker product workflow. -function applicationState = setPoints(applicationState, points) -%SETPOINTS Store one frame's ordered points and invalidate stale exports. +function applicationState = setPoints(applicationState, points, callbackContext) +%SETPOINTS Store one frame's ordered points and update its autosave. total = numel(applicationState.project.annotations.skeleton.pointIds); status = "draft"; if isempty(points) @@ -15,4 +15,6 @@ "manual", ones(size(points, 1), 1)); applicationState = ... video_marker.resultFiles.clearExportState(applicationState); +applicationState = video_marker.sessionControl.saveAutosave( ... + applicationState, callbackContext); end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m index 8db588dc8..22d76ac11 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m @@ -7,7 +7,7 @@ end points(end, :) = []; frame = state.session.cache.frameIndex; -state = video_marker.markerEditing.setPoints(state, points); +state = video_marker.markerEditing.setPoints(state, points, context); context.log("info", "video_marker.markerediting.undo.status", ... "Undid the last point on frame " + string(frame) + "."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m index f165ff1ad..2a3975de1 100644 --- a/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m @@ -47,6 +47,7 @@ state.project.parameters.coordinateEndFrame = ... max(1, payload.videoInfo.frameCount); state = video_marker.resultFiles.clearExportState(state); +state = video_marker.sessionControl.saveAutosave(state, context); context.log("info", "video_marker.resultfiles.importmarkers.completed", ... "Imported the marker CSV."); end diff --git a/apps/image_measurement/video_marker/+video_marker/definition.m b/apps/image_measurement/video_marker/+video_marker/definition.m index 12caa3a89..5689d14ca 100644 --- a/apps/image_measurement/video_marker/+video_marker/definition.m +++ b/apps/image_measurement/video_marker/+video_marker/definition.m @@ -5,8 +5,8 @@ app = labkit.app.Definition( ... Entrypoint="labkit_VideoMarker_app", AppId="video_marker", ... Title="Video Marker", DisplayName="Video Marker", ... - Family="Image Measurement", AppVersion="1.7.1", ... - Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.7.2", ... + Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=video_marker.projectSpec(), ... CreateSession=@video_marker.createSession, ... diff --git a/docs/apps/gait/gait-analysis/README.md b/docs/apps/gait/gait-analysis/README.md index 1dce2570e..c1b611cae 100644 --- a/docs/apps/gait/gait-analysis/README.md +++ b/docs/apps/gait/gait-analysis/README.md @@ -71,11 +71,18 @@ names; `iliac_crest` is accepted for the iliac role. ### 2. Analyze And Review One Step Choose **Run analysis**. Select a row in the step table or use **Previous -step** and **Next step**. The workspace then shows only that step: +step** and **Next step**. The 2-by-2 workspace shows: 1. all skeleton poses from lift-off through landing, with point trajectories; 2. hip, knee, and ankle angle traces; -3. iliac-hip, hip-knee, knee-ankle, and ankle-foot length traces. +3. iliac-hip, hip-knee, knee-ankle, and ankle-foot length traces; +4. the complete recording's overlaid skeletons and all named point + trajectories as persistent context. + +Both spatial plots preserve equal X/Y data units so gait shape is not stretched +by the available panel geometry. Loading a new source, running analysis, or +changing the selected step fits the new data once; later redraws preserve the +user's zoom. The skeleton plot annotates swing duration, step length, iliac/hip/knee/ankle/ foot translations, and each joint's minimum, maximum, and range of motion. This diff --git a/docs/apps/image-measurement/video-marker/README.md b/docs/apps/image-measurement/video-marker/README.md index ec504f577..78e39263b 100644 --- a/docs/apps/image-measurement/video-marker/README.md +++ b/docs/apps/image-measurement/video-marker/README.md @@ -2,7 +2,7 @@ Video Marker defines an ordered landmark skeleton, records coordinates across video frames, predicts forward positions between manual anchors, and saves a -portable project with an explicit source-adjacent autosave copy. +portable project with a source-adjacent autosave copy. ## Requirements And Launch @@ -22,8 +22,10 @@ loader as the window's top-level Load State action and accepts an explicit project or compatible autosave. **Save autosave** immediately updates `Video Marker Autosaves/