diff --git a/LoopLibCore/sources/pdfdiff.cpp b/LoopLibCore/sources/pdfdiff.cpp index 942acda47..8d9c0b9f3 100644 --- a/LoopLibCore/sources/pdfdiff.cpp +++ b/LoopLibCore/sources/pdfdiff.cpp @@ -31,7 +31,9 @@ #include "pdfjobscheduler.h" #include "pdfpainter.h" +#include #include +#include #include "pdfdbgheap.h" @@ -90,7 +92,6 @@ PDFDiff::PDFDiff(QObject* parent) : m_cancelled(false), m_textAnalysisAlgorithm(PDFDocumentTextFlowFactory::Algorithm::Layout) { - } PDFDiff::~PDFDiff() @@ -152,7 +153,7 @@ void PDFDiff::start() spec.operationId = QStringLiteral("pdf.diff"); m_activeJobId = pdf::PDFJobScheduler::global().submit(spec, [this](pdf::PDFJobContext& context) - { + { if (context.isCancellationRequested()) { m_cancelled = true; @@ -259,7 +260,7 @@ void PDFDiff::stepProgress() struct PDFDiffPageContext { PDFInteger pageIndex = 0; - std::array pageHash = { }; + std::array pageHash = {}; PDFPrecompiledPage::GraphicPieceInfos graphicPieces; PDFDocumentTextFlow text; }; @@ -377,13 +378,13 @@ void PDFDiff::performSteps(const std::vector& leftPages, std::vector rightPreparedPages; PDFDiffHelper::PageSequence pageSequence; - std::map pageMatches; // Indices are real page indices, not indices to page contexts + std::map pageMatches; // Indices are real page indices, not indices to page contexts auto createDiffPageContext = [](auto pageIndex) { - PDFDiffPageContext context; - context.pageIndex = pageIndex; - return context; + PDFDiffPageContext context; + context.pageIndex = pageIndex; + return context; }; std::transform(leftPages.cbegin(), leftPages.cend(), std::back_inserter(leftPreparedPages), createDiffPageContext); std::transform(rightPages.cbegin(), rightPages.cend(), std::back_inserter(rightPreparedPages), createDiffPageContext); @@ -731,7 +732,7 @@ void PDFDiff::performCompare(const std::vector& leftPrepared compareCharacters); algorithm.perform(); PDFAlgorithmLongestCommonSubsequenceBase::Sequence sequence = algorithm.getSequence(); - PDFAlgorithmLongestCommonSubsequenceBase::markSequence(sequence, { }, { }); + PDFAlgorithmLongestCommonSubsequenceBase::markSequence(sequence, {}, {}); PDFAlgorithmLongestCommonSubsequenceBase::SequenceItemRanges modifiedRanges = PDFAlgorithmLongestCommonSubsequenceBase::getModifiedRanges(sequence); // Merge modified sequences separated by just space @@ -813,9 +814,9 @@ void PDFDiff::performCompare(const std::vector& leftPrepared pageIndex1 = textItem->pageIndex; } - if (static_cast< std::size_t >( textCompareItem.charIndex ) + textCompareItem.charCount <= textItem->characterBoundingRects.size()) + if (static_cast(textCompareItem.charIndex) + textCompareItem.charCount <= textItem->characterBoundingRects.size()) { - const size_t startIndex = textCompareItem.charIndex; + const size_t startIndex = textCompareItem.charIndex; const size_t endIndex = startIndex + textCompareItem.charCount; for (size_t i = startIndex; i < endIndex; ++i) @@ -841,9 +842,9 @@ void PDFDiff::performCompare(const std::vector& leftPrepared pageIndex2 = textItem->pageIndex; } - if (static_cast< std::size_t >(textCompareItem.charIndex) + textCompareItem.charCount <= textItem->characterBoundingRects.size()) + if (static_cast(textCompareItem.charIndex) + textCompareItem.charCount <= textItem->characterBoundingRects.size()) { - const size_t startIndex = textCompareItem.charIndex; + const size_t startIndex = textCompareItem.charIndex; const size_t endIndex = startIndex + textCompareItem.charCount; for (size_t i = startIndex; i < endIndex; ++i) @@ -973,7 +974,6 @@ void PDFDiff::setTextAnalysisAlgorithm(PDFDocumentTextFlowFactory::Algorithm tex PDFDiffResult::PDFDiffResult() : m_result(true) { - } void PDFDiffResult::addPageMoved(PDFInteger pageIndex1, PDFInteger pageIndex2) @@ -1839,12 +1839,10 @@ PDFDiffResultNavigator::PDFDiffResultNavigator(QObject* parent) : m_diffResult(nullptr), m_currentIndex(0) { - } PDFDiffResultNavigator::~PDFDiffResultNavigator() { - } void PDFDiffResultNavigator::setResult(const PDFDiffResult* diffResult) diff --git a/LoopLibCore/sources/pdfdiff.h b/LoopLibCore/sources/pdfdiff.h index 196f442ff..01b63ccdc 100644 --- a/LoopLibCore/sources/pdfdiff.h +++ b/LoopLibCore/sources/pdfdiff.h @@ -49,21 +49,21 @@ class LOOPLIBCORESHARED_EXPORT PDFDiffResult enum class Type : uint32_t { - Invalid = 0x0000, - PageMoved = 0x0001, - PageAdded = 0x0002, - PageRemoved = 0x0004, - RemovedTextCharContent = 0x0008, - RemovedVectorGraphicContent = 0x0010, - RemovedImageContent = 0x0020, - RemovedShadingContent = 0x0040, - AddedTextCharContent = 0x0080, - AddedVectorGraphicContent = 0x0100, - AddedImageContent = 0x0200, - AddedShadingContent = 0x0400, - TextReplaced = 0x0800, - TextAdded = 0x1000, - TextRemoved = 0x2000, + Invalid = 0x0000, + PageMoved = 0x0001, + PageAdded = 0x0002, + PageRemoved = 0x0004, + RemovedTextCharContent = 0x0008, + RemovedVectorGraphicContent = 0x0010, + RemovedImageContent = 0x0020, + RemovedShadingContent = 0x0040, + AddedTextCharContent = 0x0080, + AddedVectorGraphicContent = 0x0100, + AddedImageContent = 0x0200, + AddedShadingContent = 0x0400, + TextReplaced = 0x0800, + TextAdded = 0x1000, + TextRemoved = 0x2000, }; struct PageSequenceItem @@ -232,7 +232,7 @@ class LOOPLIBCORESHARED_EXPORT PDFDiffResult void addRectRight(Difference& difference, QRectF rect); Differences m_differences; - RectInfos m_rects; ///< Rectangles with page indices + RectInfos m_rects; ///< Rectangles with page indices PDFOperationResult m_result; QStringList m_strings; uint32_t m_typeFlags = 0; @@ -300,14 +300,14 @@ class LOOPLIBCORESHARED_EXPORT PDFDiff : public QObject enum Option { - None = 0x0000, - Asynchronous = 0x0001, ///< Compare document asynchronously - PC_Text = 0x0002, ///< Use text to compare pages (determine, which pages correspond to each other) - PC_VectorGraphics = 0x0004, ///< Use vector graphics to compare pages (determine, which pages correspond to each other) - PC_Images = 0x0008, ///< Use images to compare pages (determine, which pages correspond to each other) - PC_Mesh = 0x0010, ///< Use mesh to compare pages (determine, which pages correspond to each other) - CompareTextsAsVector = 0x0020, ///< Compare texts as vector graphics - CompareWords = 0x0040, ///< Compare words, not just characters + None = 0x0000, + Asynchronous = 0x0001, ///< Compare document asynchronously + PC_Text = 0x0002, ///< Use text to compare pages (determine, which pages correspond to each other) + PC_VectorGraphics = 0x0004, ///< Use vector graphics to compare pages (determine, which pages correspond to each other) + PC_Images = 0x0008, ///< Use images to compare pages (determine, which pages correspond to each other) + PC_Mesh = 0x0010, ///< Use mesh to compare pages (determine, which pages correspond to each other) + CompareTextsAsVector = 0x0020, ///< Compare texts as vector graphics + CompareWords = 0x0040, ///< Compare words, not just characters }; Q_DECLARE_FLAGS(Options, Option) @@ -356,7 +356,6 @@ class LOOPLIBCORESHARED_EXPORT PDFDiff : public QObject void comparationFinished(); private: - enum Steps { StepExtractContentLeftDocument, @@ -409,4 +408,4 @@ class LOOPLIBCORESHARED_EXPORT PDFDiff : public QObject } // namespace pdf -#endif // PDFDIFF_H +#endif // PDFDIFF_H diff --git a/UnitTests/testdata/lifecycle/failure-rollback-history-minimized.json b/UnitTests/testdata/lifecycle/failure-rollback-history-minimized.json new file mode 100644 index 000000000..b7188498f --- /dev/null +++ b/UnitTests/testdata/lifecycle/failure-rollback-history-minimized.json @@ -0,0 +1,29 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363361, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "9073021129658994722" + }, + { + "index": 1, + "kind": "save-reopen", + "argument": "5643642477061534660" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "rollback-history-mutated", + "shrink_history": [ + 64, + 2 + ] +} diff --git a/UnitTests/testdata/lifecycle/failure-source-overwritten-minimized.json b/UnitTests/testdata/lifecycle/failure-source-overwritten-minimized.json new file mode 100644 index 000000000..f4ca466da --- /dev/null +++ b/UnitTests/testdata/lifecycle/failure-source-overwritten-minimized.json @@ -0,0 +1,24 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363361, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "9073021129658994722" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "source-overwritten", + "shrink_history": [ + 64, + 1 + ] +} diff --git a/UnitTests/testdata/lifecycle/failure-stale-result-minimized.json b/UnitTests/testdata/lifecycle/failure-stale-result-minimized.json new file mode 100644 index 000000000..a66e6fcf3 --- /dev/null +++ b/UnitTests/testdata/lifecycle/failure-stale-result-minimized.json @@ -0,0 +1,24 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363361, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "9073021129658994722" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "stale-result-accepted", + "shrink_history": [ + 64, + 1 + ] +} diff --git a/UnitTests/testdata/lifecycle/manifest.json b/UnitTests/testdata/lifecycle/manifest.json new file mode 100644 index 000000000..3b1a0f125 --- /dev/null +++ b/UnitTests/testdata/lifecycle/manifest.json @@ -0,0 +1,59 @@ +{ + "schema_kind": "loop-lifecycle-corpus", + "schema_version": 1, + "max_commands": 64, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "passing_traces": [ + { + "seed": 539363361, + "trace_file": "seed-20260821.json", + "command_count": 64, + "sha256": "3e1f42af36600a36a81e67fa57fcd580db12bbfe99f7dd539d592a5cde1f70ed", + "observed_result": "invariants-held" + }, + { + "seed": 539363585, + "trace_file": "seed-20260901.json", + "command_count": 64, + "sha256": "1a86d6679ee11d4bd8fd66aa2fb90ac41b0d6a70c1d0efa0d798158ca5488fb6", + "observed_result": "invariants-held" + }, + { + "seed": 539363590, + "trace_file": "seed-20260906.json", + "command_count": 64, + "sha256": "d5d2ae6d4dbee887dd454cbe262938bbd78f8d286a5d802e784e1eecde436d19", + "observed_result": "invariants-held" + }, + { + "seed": 539365889, + "trace_file": "seed-20261201.json", + "command_count": 64, + "sha256": "13c6207cfaea9553f3a62d59a91b6b1fe22c3fae2c46405cc6309e5d9bc3cd67", + "observed_result": "invariants-held" + } + ], + "failure_traces": [ + { + "trace_file": "failure-stale-result-minimized.json", + "replay_profile": "inject-stale-acceptance", + "expected_violation": "stale-result-accepted", + "command_count": 1, + "sha256": "d4eb0ed9fc6819f98ba9b3361faf854e7a04e6b1bae510280659baaa20e508d9" + }, + { + "trace_file": "failure-source-overwritten-minimized.json", + "replay_profile": "inject-source-overwrite", + "expected_violation": "source-overwritten", + "command_count": 1, + "sha256": "f0af352cb2c2587ee46dcbfe7adac9e880de124f62c65d06a5763ca087fde950" + }, + { + "trace_file": "failure-rollback-history-minimized.json", + "replay_profile": "inject-history-mutation", + "expected_violation": "rollback-history-mutated", + "command_count": 2, + "sha256": "01d976d572d0b0be3f063085f45ec4bd6bc2536eb0502a54e5658fcf53c1664b" + } + ] +} diff --git a/UnitTests/testdata/lifecycle/seed-20260821.json b/UnitTests/testdata/lifecycle/seed-20260821.json index d93dd6413..220d4b4cd 100644 --- a/UnitTests/testdata/lifecycle/seed-20260821.json +++ b/UnitTests/testdata/lifecycle/seed-20260821.json @@ -161,8 +161,168 @@ }, { "index": 31, + "kind": "open", + "argument": "5013354945698045938" + }, + { + "index": 32, + "kind": "cancel", + "argument": "11197343878333884823" + }, + { + "index": 33, + "kind": "open", + "argument": "1066545918793467191" + }, + { + "index": 34, + "kind": "open", + "argument": "11321594274765674640" + }, + { + "index": 35, + "kind": "replace-revision", + "argument": "14283579915496753648" + }, + { + "index": 36, + "kind": "cancel", + "argument": "17863176575058370743" + }, + { + "index": 37, + "kind": "cancel", + "argument": "13685223271187027546" + }, + { + "index": 38, + "kind": "rollback", + "argument": "6748766380856173228" + }, + { + "index": 39, + "kind": "open", + "argument": "9604188461958675080" + }, + { + "index": 40, + "kind": "rollback", + "argument": "976639850783419330" + }, + { + "index": 41, + "kind": "rollback", + "argument": "11029822007145908066" + }, + { + "index": 42, + "kind": "cancel", + "argument": "9746768580576956141" + }, + { + "index": 43, + "kind": "cancel", + "argument": "2957123111136537141" + }, + { + "index": 44, + "kind": "save-reopen", + "argument": "1534177130836295959" + }, + { + "index": 45, + "kind": "rollback", + "argument": "11824983345393850086" + }, + { + "index": 46, + "kind": "replace-revision", + "argument": "10514359223331479425" + }, + { + "index": 47, + "kind": "cancel", + "argument": "14023964117627326189" + }, + { + "index": 48, + "kind": "save-reopen", + "argument": "1159594577666376080" + }, + { + "index": 49, + "kind": "cancel", + "argument": "5253177320728505183" + }, + { + "index": 50, + "kind": "render-preflight", + "argument": "15684130718138178927" + }, + { + "index": 51, + "kind": "save-reopen", + "argument": "17776524391223397062" + }, + { + "index": 52, + "kind": "open", + "argument": "7526981500392177173" + }, + { + "index": 53, + "kind": "cancel", + "argument": "6451505757555346948" + }, + { + "index": 54, + "kind": "rollback", + "argument": "8369269387004969562" + }, + { + "index": 55, + "kind": "render-preflight", + "argument": "16441652591749629825" + }, + { + "index": 56, + "kind": "save-reopen", + "argument": "6520366602141516188" + }, + { + "index": 57, + "kind": "open", + "argument": "17566257490975440028" + }, + { + "index": 58, + "kind": "replace-revision", + "argument": "8693436164466649310" + }, + { + "index": 59, + "kind": "rollback", + "argument": "10201475227484907525" + }, + { + "index": 60, + "kind": "cancel", + "argument": "8702028942361848727" + }, + { + "index": 61, + "kind": "cancel", + "argument": "7680792602989575571" + }, + { + "index": 62, + "kind": "open", + "argument": "18431997874931135203" + }, + { + "index": 63, "kind": "close", - "argument": "14977176080434032300" + "argument": "17475550920163749414" } ], "expected_invariants": [ @@ -170,5 +330,7 @@ "cancel-is-terminal", "stale-results-rejected", "history-append-only" - ] + ], + "observed_result": "invariants-held", + "shrink_history": [] } diff --git a/UnitTests/testdata/lifecycle/seed-20260901.json b/UnitTests/testdata/lifecycle/seed-20260901.json new file mode 100644 index 000000000..bc300a86e --- /dev/null +++ b/UnitTests/testdata/lifecycle/seed-20260901.json @@ -0,0 +1,336 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363585, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "6034458062541543431" + }, + { + "index": 1, + "kind": "render-preflight", + "argument": "11557984931371038396" + }, + { + "index": 2, + "kind": "cancel", + "argument": "15318040145324592085" + }, + { + "index": 3, + "kind": "replace-revision", + "argument": "10503351105320755683" + }, + { + "index": 4, + "kind": "save-reopen", + "argument": "8670814518977129462" + }, + { + "index": 5, + "kind": "rollback", + "argument": "2056252673123062811" + }, + { + "index": 6, + "kind": "rollback", + "argument": "8796674579583408103" + }, + { + "index": 7, + "kind": "cancel", + "argument": "7529703556190505138" + }, + { + "index": 8, + "kind": "save-reopen", + "argument": "3498278710874659391" + }, + { + "index": 9, + "kind": "cancel", + "argument": "248165894482323742" + }, + { + "index": 10, + "kind": "render-preflight", + "argument": "8409758025133656170" + }, + { + "index": 11, + "kind": "replace-revision", + "argument": "13092916002760833247" + }, + { + "index": 12, + "kind": "rollback", + "argument": "16280336030479743977" + }, + { + "index": 13, + "kind": "cancel", + "argument": "7836534050970960017" + }, + { + "index": 14, + "kind": "rollback", + "argument": "13865125085764645207" + }, + { + "index": 15, + "kind": "render-preflight", + "argument": "16783752467326883490" + }, + { + "index": 16, + "kind": "open", + "argument": "6438395466543381809" + }, + { + "index": 17, + "kind": "cancel", + "argument": "3076831087101073067" + }, + { + "index": 18, + "kind": "render-preflight", + "argument": "4370711324695542987" + }, + { + "index": 19, + "kind": "rollback", + "argument": "3509485308483665705" + }, + { + "index": 20, + "kind": "cancel", + "argument": "16165119337040266006" + }, + { + "index": 21, + "kind": "cancel", + "argument": "7224592201735533095" + }, + { + "index": 22, + "kind": "rollback", + "argument": "13014678599744179864" + }, + { + "index": 23, + "kind": "replace-revision", + "argument": "1665540552503060410" + }, + { + "index": 24, + "kind": "replace-revision", + "argument": "6306584685703776445" + }, + { + "index": 25, + "kind": "cancel", + "argument": "12331126889054291033" + }, + { + "index": 26, + "kind": "rollback", + "argument": "15788316041716919364" + }, + { + "index": 27, + "kind": "render-preflight", + "argument": "15279607423935238490" + }, + { + "index": 28, + "kind": "render-preflight", + "argument": "4289324551056899125" + }, + { + "index": 29, + "kind": "replace-revision", + "argument": "1589285450794043930" + }, + { + "index": 30, + "kind": "render-preflight", + "argument": "13188742041876043665" + }, + { + "index": 31, + "kind": "render-preflight", + "argument": "8990961894696766626" + }, + { + "index": 32, + "kind": "save-reopen", + "argument": "17571388497116038286" + }, + { + "index": 33, + "kind": "cancel", + "argument": "1800772835607805823" + }, + { + "index": 34, + "kind": "render-preflight", + "argument": "7726785530441472681" + }, + { + "index": 35, + "kind": "save-reopen", + "argument": "9912815472792896467" + }, + { + "index": 36, + "kind": "cancel", + "argument": "16278907643064479574" + }, + { + "index": 37, + "kind": "render-preflight", + "argument": "11900770073273755717" + }, + { + "index": 38, + "kind": "open", + "argument": "15479172974279900518" + }, + { + "index": 39, + "kind": "render-preflight", + "argument": "17526234272234945023" + }, + { + "index": 40, + "kind": "replace-revision", + "argument": "7482245642670188713" + }, + { + "index": 41, + "kind": "cancel", + "argument": "3593688926539614798" + }, + { + "index": 42, + "kind": "rollback", + "argument": "11034052604634052349" + }, + { + "index": 43, + "kind": "rollback", + "argument": "17790316739075330643" + }, + { + "index": 44, + "kind": "rollback", + "argument": "9530928495526821296" + }, + { + "index": 45, + "kind": "save-reopen", + "argument": "16668919129355485356" + }, + { + "index": 46, + "kind": "cancel", + "argument": "15901814786715132587" + }, + { + "index": 47, + "kind": "replace-revision", + "argument": "7191992002674859755" + }, + { + "index": 48, + "kind": "replace-revision", + "argument": "1883587719972258900" + }, + { + "index": 49, + "kind": "rollback", + "argument": "8769943291277525666" + }, + { + "index": 50, + "kind": "open", + "argument": "5761950815985536249" + }, + { + "index": 51, + "kind": "open", + "argument": "13922630760902466604" + }, + { + "index": 52, + "kind": "replace-revision", + "argument": "15821748741709964980" + }, + { + "index": 53, + "kind": "replace-revision", + "argument": "11564848330980856789" + }, + { + "index": 54, + "kind": "open", + "argument": "4026995906742413101" + }, + { + "index": 55, + "kind": "save-reopen", + "argument": "5722345623930212980" + }, + { + "index": 56, + "kind": "open", + "argument": "17101291144489736303" + }, + { + "index": 57, + "kind": "cancel", + "argument": "9963324368094161009" + }, + { + "index": 58, + "kind": "cancel", + "argument": "3511687468419819300" + }, + { + "index": 59, + "kind": "rollback", + "argument": "4887354383063994991" + }, + { + "index": 60, + "kind": "open", + "argument": "6360821787086858277" + }, + { + "index": 61, + "kind": "replace-revision", + "argument": "6165075258081952870" + }, + { + "index": 62, + "kind": "open", + "argument": "17594608256850659803" + }, + { + "index": 63, + "kind": "close", + "argument": "8678437679157409518" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "invariants-held", + "shrink_history": [] +} diff --git a/UnitTests/testdata/lifecycle/seed-20260906.json b/UnitTests/testdata/lifecycle/seed-20260906.json new file mode 100644 index 000000000..a47f7f624 --- /dev/null +++ b/UnitTests/testdata/lifecycle/seed-20260906.json @@ -0,0 +1,336 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363590, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "14230604003345983359" + }, + { + "index": 1, + "kind": "render-preflight", + "argument": "1588026375595219817" + }, + { + "index": 2, + "kind": "cancel", + "argument": "16797393914780404380" + }, + { + "index": 3, + "kind": "replace-revision", + "argument": "10487858659920104814" + }, + { + "index": 4, + "kind": "save-reopen", + "argument": "8777801630590435565" + }, + { + "index": 5, + "kind": "rollback", + "argument": "10478246686685788487" + }, + { + "index": 6, + "kind": "replace-revision", + "argument": "8667998413670437203" + }, + { + "index": 7, + "kind": "rollback", + "argument": "13146596628838623446" + }, + { + "index": 8, + "kind": "replace-revision", + "argument": "1096037477704783486" + }, + { + "index": 9, + "kind": "rollback", + "argument": "12321288239470446300" + }, + { + "index": 10, + "kind": "save-reopen", + "argument": "16576694838748259565" + }, + { + "index": 11, + "kind": "rollback", + "argument": "1128805223659789048" + }, + { + "index": 12, + "kind": "replace-revision", + "argument": "3557646355500177479" + }, + { + "index": 13, + "kind": "render-preflight", + "argument": "16303864045696106717" + }, + { + "index": 14, + "kind": "replace-revision", + "argument": "11011505654670955743" + }, + { + "index": 15, + "kind": "replace-revision", + "argument": "6747742078117334680" + }, + { + "index": 16, + "kind": "open", + "argument": "13757629247951878051" + }, + { + "index": 17, + "kind": "save-reopen", + "argument": "15410281890512757605" + }, + { + "index": 18, + "kind": "cancel", + "argument": "12613518827303110357" + }, + { + "index": 19, + "kind": "open", + "argument": "3662771228197864827" + }, + { + "index": 20, + "kind": "open", + "argument": "15371419352448802939" + }, + { + "index": 21, + "kind": "render-preflight", + "argument": "2742528321361811816" + }, + { + "index": 22, + "kind": "replace-revision", + "argument": "16881758039184367644" + }, + { + "index": 23, + "kind": "replace-revision", + "argument": "7515271925486513300" + }, + { + "index": 24, + "kind": "cancel", + "argument": "11110460629007302314" + }, + { + "index": 25, + "kind": "render-preflight", + "argument": "7180552353081869726" + }, + { + "index": 26, + "kind": "replace-revision", + "argument": "16764869896933110734" + }, + { + "index": 27, + "kind": "rollback", + "argument": "7397236548083670144" + }, + { + "index": 28, + "kind": "open", + "argument": "4472030064572726324" + }, + { + "index": 29, + "kind": "cancel", + "argument": "14395696145476499051" + }, + { + "index": 30, + "kind": "render-preflight", + "argument": "10022073564175744054" + }, + { + "index": 31, + "kind": "render-preflight", + "argument": "6854377914811902404" + }, + { + "index": 32, + "kind": "replace-revision", + "argument": "8208756044759640812" + }, + { + "index": 33, + "kind": "cancel", + "argument": "13946846204961241087" + }, + { + "index": 34, + "kind": "cancel", + "argument": "16381313552732617312" + }, + { + "index": 35, + "kind": "replace-revision", + "argument": "5100374929593666101" + }, + { + "index": 36, + "kind": "open", + "argument": "13722538589771077530" + }, + { + "index": 37, + "kind": "save-reopen", + "argument": "6905591012119966347" + }, + { + "index": 38, + "kind": "replace-revision", + "argument": "8951784006284481126" + }, + { + "index": 39, + "kind": "rollback", + "argument": "5746369506577447091" + }, + { + "index": 40, + "kind": "cancel", + "argument": "12809106175243204014" + }, + { + "index": 41, + "kind": "replace-revision", + "argument": "17148102063643212214" + }, + { + "index": 42, + "kind": "open", + "argument": "8385593886831664121" + }, + { + "index": 43, + "kind": "render-preflight", + "argument": "9157938091105991117" + }, + { + "index": 44, + "kind": "replace-revision", + "argument": "17112200312213409498" + }, + { + "index": 45, + "kind": "render-preflight", + "argument": "7375059468494641751" + }, + { + "index": 46, + "kind": "save-reopen", + "argument": "4282029775557665062" + }, + { + "index": 47, + "kind": "cancel", + "argument": "9638691280653838860" + }, + { + "index": 48, + "kind": "cancel", + "argument": "12993024188932119725" + }, + { + "index": 49, + "kind": "rollback", + "argument": "7500018228949373022" + }, + { + "index": 50, + "kind": "rollback", + "argument": "15331566196732507261" + }, + { + "index": 51, + "kind": "rollback", + "argument": "6657391704554243592" + }, + { + "index": 52, + "kind": "replace-revision", + "argument": "13324463767618875400" + }, + { + "index": 53, + "kind": "open", + "argument": "12671486952690650551" + }, + { + "index": 54, + "kind": "save-reopen", + "argument": "4709526536834903823" + }, + { + "index": 55, + "kind": "render-preflight", + "argument": "15059432513886933886" + }, + { + "index": 56, + "kind": "rollback", + "argument": "3420096264905400964" + }, + { + "index": 57, + "kind": "rollback", + "argument": "8902507161373401593" + }, + { + "index": 58, + "kind": "rollback", + "argument": "17518160122279735234" + }, + { + "index": 59, + "kind": "replace-revision", + "argument": "2758098344497143889" + }, + { + "index": 60, + "kind": "rollback", + "argument": "4378049450544246792" + }, + { + "index": 61, + "kind": "replace-revision", + "argument": "12750877198838246339" + }, + { + "index": 62, + "kind": "render-preflight", + "argument": "5156819502440901355" + }, + { + "index": 63, + "kind": "close", + "argument": "13189511042334219285" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "invariants-held", + "shrink_history": [] +} diff --git a/UnitTests/testdata/lifecycle/seed-20261201.json b/UnitTests/testdata/lifecycle/seed-20261201.json new file mode 100644 index 000000000..7869bc5bc --- /dev/null +++ b/UnitTests/testdata/lifecycle/seed-20261201.json @@ -0,0 +1,336 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539365889, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "16054040628713633634" + }, + { + "index": 1, + "kind": "render-preflight", + "argument": "13709248772244232181" + }, + { + "index": 2, + "kind": "cancel", + "argument": "15061211214994535425" + }, + { + "index": 3, + "kind": "replace-revision", + "argument": "6033347758867561771" + }, + { + "index": 4, + "kind": "save-reopen", + "argument": "14743479986073434988" + }, + { + "index": 5, + "kind": "rollback", + "argument": "10370917642072392039" + }, + { + "index": 6, + "kind": "save-reopen", + "argument": "6972387814645228182" + }, + { + "index": 7, + "kind": "rollback", + "argument": "15464864822259309587" + }, + { + "index": 8, + "kind": "open", + "argument": "773992352547029268" + }, + { + "index": 9, + "kind": "save-reopen", + "argument": "9707748779095730431" + }, + { + "index": 10, + "kind": "rollback", + "argument": "9496772363638990753" + }, + { + "index": 11, + "kind": "open", + "argument": "2214694760053319153" + }, + { + "index": 12, + "kind": "cancel", + "argument": "14030004003817890535" + }, + { + "index": 13, + "kind": "rollback", + "argument": "7583470035862913580" + }, + { + "index": 14, + "kind": "render-preflight", + "argument": "12074258694667358269" + }, + { + "index": 15, + "kind": "open", + "argument": "13761973405340247722" + }, + { + "index": 16, + "kind": "save-reopen", + "argument": "5171938990575051653" + }, + { + "index": 17, + "kind": "rollback", + "argument": "2345211413140230101" + }, + { + "index": 18, + "kind": "save-reopen", + "argument": "1628523258763850760" + }, + { + "index": 19, + "kind": "render-preflight", + "argument": "5038585513152486872" + }, + { + "index": 20, + "kind": "render-preflight", + "argument": "253354491755752116" + }, + { + "index": 21, + "kind": "render-preflight", + "argument": "7229688035849407956" + }, + { + "index": 22, + "kind": "save-reopen", + "argument": "13044934373465555695" + }, + { + "index": 23, + "kind": "rollback", + "argument": "10895625138792824209" + }, + { + "index": 24, + "kind": "rollback", + "argument": "18260822582291749403" + }, + { + "index": 25, + "kind": "rollback", + "argument": "2168527590852211366" + }, + { + "index": 26, + "kind": "save-reopen", + "argument": "668363155651112498" + }, + { + "index": 27, + "kind": "open", + "argument": "12560823782655447857" + }, + { + "index": 28, + "kind": "rollback", + "argument": "7363036765673601358" + }, + { + "index": 29, + "kind": "open", + "argument": "6489798371295961759" + }, + { + "index": 30, + "kind": "open", + "argument": "9462842297514227416" + }, + { + "index": 31, + "kind": "render-preflight", + "argument": "14844150999868734056" + }, + { + "index": 32, + "kind": "render-preflight", + "argument": "16421023710682210765" + }, + { + "index": 33, + "kind": "cancel", + "argument": "1544723242647003896" + }, + { + "index": 34, + "kind": "cancel", + "argument": "2854918988201860765" + }, + { + "index": 35, + "kind": "open", + "argument": "5988409256948600677" + }, + { + "index": 36, + "kind": "cancel", + "argument": "10642396680245204812" + }, + { + "index": 37, + "kind": "open", + "argument": "8794761475480588469" + }, + { + "index": 38, + "kind": "replace-revision", + "argument": "11945211581858129974" + }, + { + "index": 39, + "kind": "save-reopen", + "argument": "9658355568562264202" + }, + { + "index": 40, + "kind": "cancel", + "argument": "1218796795764106474" + }, + { + "index": 41, + "kind": "open", + "argument": "2130223485059635384" + }, + { + "index": 42, + "kind": "replace-revision", + "argument": "8587225387843416840" + }, + { + "index": 43, + "kind": "save-reopen", + "argument": "10591484320609480747" + }, + { + "index": 44, + "kind": "render-preflight", + "argument": "4201101714001588171" + }, + { + "index": 45, + "kind": "replace-revision", + "argument": "1167964152539473042" + }, + { + "index": 46, + "kind": "render-preflight", + "argument": "5954331234777416219" + }, + { + "index": 47, + "kind": "open", + "argument": "15264214914524986704" + }, + { + "index": 48, + "kind": "render-preflight", + "argument": "13625014935478822327" + }, + { + "index": 49, + "kind": "save-reopen", + "argument": "3124866179075240274" + }, + { + "index": 50, + "kind": "open", + "argument": "10702406781575412905" + }, + { + "index": 51, + "kind": "rollback", + "argument": "4749815308794565093" + }, + { + "index": 52, + "kind": "render-preflight", + "argument": "2325484016830293738" + }, + { + "index": 53, + "kind": "render-preflight", + "argument": "14778039963435660010" + }, + { + "index": 54, + "kind": "rollback", + "argument": "6018898021606890206" + }, + { + "index": 55, + "kind": "replace-revision", + "argument": "14574415953043251551" + }, + { + "index": 56, + "kind": "render-preflight", + "argument": "17940381580291829945" + }, + { + "index": 57, + "kind": "open", + "argument": "13517690949233321384" + }, + { + "index": 58, + "kind": "replace-revision", + "argument": "8630776572484843022" + }, + { + "index": 59, + "kind": "save-reopen", + "argument": "10155335922637887513" + }, + { + "index": 60, + "kind": "open", + "argument": "899533631255863596" + }, + { + "index": 61, + "kind": "open", + "argument": "3615294935064304551" + }, + { + "index": 62, + "kind": "replace-revision", + "argument": "1533225401934010033" + }, + { + "index": 63, + "kind": "close", + "argument": "11231086953340464765" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "invariants-held", + "shrink_history": [] +} diff --git a/UnitTests/tst_lifecycletest.cpp b/UnitTests/tst_lifecycletest.cpp index 598913422..2f1f0a99c 100644 --- a/UnitTests/tst_lifecycletest.cpp +++ b/UnitTests/tst_lifecycletest.cpp @@ -21,6 +21,7 @@ // SOFTWARE. #include "pdfartifactstore.h" +#include "pdfapplicationidentity.h" #include "pdfdocumentbuilder.h" #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" @@ -33,9 +34,12 @@ #include #include #include +#include #include #include +#include #include +#include #include #include @@ -49,7 +53,14 @@ class LifecycleTest : public QObject Q_OBJECT private slots: + void initTestCase(); void boundedTraceGenerationIsDeterministic(); + void qualificationCorpusSchemasAreValid(); + void qualificationCorpusSeedsMatchGoldenTraces(); + void qualificationCorpusReplayPreservesInvariants(); + void deltaDebugShrinkPreservesFailure(); + void promotedFailureTracesMatchExpectedViolations(); + void crossPlatformCorpusReportIsStable(); void seededSequencePreservesInvariants(); void injectedStaleResultIsCaught(); void injectedOverwriteIsCaught(); @@ -59,6 +70,26 @@ private slots: namespace { +constexpr int kMaxTraceCommands = 64; +constexpr quint64 kPrimarySeed = UINT64_C(0x20260821); + +const QStringList kExpectedInvariants = { + QStringLiteral("source-immutable"), + QStringLiteral("cancel-is-terminal"), + QStringLiteral("stale-results-rejected"), + QStringLiteral("history-append-only"), +}; + +const QStringList kAllowedCommandKinds = { + QStringLiteral("open"), + QStringLiteral("render-preflight"), + QStringLiteral("cancel"), + QStringLiteral("replace-revision"), + QStringLiteral("save-reopen"), + QStringLiteral("rollback"), + QStringLiteral("close"), +}; + enum class TraceCommandKind { Open, @@ -70,6 +101,14 @@ enum class TraceCommandKind Close, }; +enum class TraceReplayProfile +{ + None, + InjectStaleAcceptance, + InjectSourceOverwrite, + InjectHistoryMutation, +}; + QString traceCommandName(TraceCommandKind kind) { switch (kind) @@ -92,6 +131,39 @@ QString traceCommandName(TraceCommandKind kind) return QStringLiteral("unknown"); } +std::optional traceCommandKindFromName(const QString& name) +{ + if (name == QStringLiteral("open")) + { + return TraceCommandKind::Open; + } + if (name == QStringLiteral("render-preflight")) + { + return TraceCommandKind::RenderPreflight; + } + if (name == QStringLiteral("cancel")) + { + return TraceCommandKind::Cancel; + } + if (name == QStringLiteral("replace-revision")) + { + return TraceCommandKind::ReplaceRevision; + } + if (name == QStringLiteral("save-reopen")) + { + return TraceCommandKind::SaveReopen; + } + if (name == QStringLiteral("rollback")) + { + return TraceCommandKind::Rollback; + } + if (name == QStringLiteral("close")) + { + return TraceCommandKind::Close; + } + return std::nullopt; +} + struct TraceCommand { TraceCommandKind kind; @@ -107,7 +179,7 @@ quint64 nextTraceRandom(quint64& state) return value ^ (value >> 31); } -QVector generateTrace(quint64 seed) +QVector generateTrace(quint64 seed, int maxCommands = kMaxTraceCommands) { const QVector activeCoverage = { TraceCommandKind::Open, @@ -118,13 +190,13 @@ QVector generateTrace(quint64 seed) TraceCommandKind::Rollback, }; QVector trace; - trace.reserve(32); + trace.reserve(maxCommands); quint64 state = seed; for (const TraceCommandKind kind : activeCoverage) { trace.append({ kind, nextTraceRandom(state) }); } - while (trace.size() < 31) + while (trace.size() < maxCommands - 1) { const auto kind = activeCoverage.at(static_cast(nextTraceRandom(state) % activeCoverage.size())); trace.append({ kind, nextTraceRandom(state) }); @@ -133,7 +205,10 @@ QVector generateTrace(quint64 seed) return trace; } -QJsonObject traceToJson(quint64 seed, const QVector& trace) +QJsonObject traceToJson(quint64 seed, + const QVector& trace, + const QString& observedResult, + const QJsonArray& shrinkHistory) { QJsonArray commands; for (qsizetype index = 0; index < trace.size(); ++index) @@ -149,11 +224,9 @@ QJsonObject traceToJson(quint64 seed, const QVector& trace) { QStringLiteral("seed"), static_cast(seed) }, { QStringLiteral("initial_artifact_digest"), pdf::PDFRunIdentity::digestBytes(QByteArrayLiteral("lifecycle-source-v1")) }, { QStringLiteral("commands"), commands }, - { QStringLiteral("expected_invariants"), QJsonArray{ - QStringLiteral("source-immutable"), - QStringLiteral("cancel-is-terminal"), - QStringLiteral("stale-results-rejected"), - QStringLiteral("history-append-only") } } + { QStringLiteral("expected_invariants"), QJsonArray::fromStringList(kExpectedInvariants) }, + { QStringLiteral("observed_result"), observedResult }, + { QStringLiteral("shrink_history"), shrinkHistory }, }; } @@ -259,58 +332,672 @@ bool appendEvent(pdf::PDFOperationHistoryStore& history, return true; } -} // namespace +struct ReplayEnvironment +{ + QTemporaryDir temporary; + pdf::PDFArtifactStore artifacts; + pdf::PDFOperationHistoryStore history; + pdf::PDFDocumentBuilder builder; + pdf::PDFDocument document; + pdf::PDFDocumentContext context; + pdf::PDFJobScheduler scheduler; + LifecycleState state; + QString activeJobId; + TraceReplayProfile profile = TraceReplayProfile::None; -void LifecycleTest::boundedTraceGenerationIsDeterministic() + ReplayEnvironment() : + artifacts(temporary.path()), + history(QDir(temporary.path()).filePath(QStringLiteral("history.sqlite3"))), + context(&document), + scheduler(1) + { + builder.appendPage(QRectF(0, 0, 100, 100)); + document = builder.build(); + context.setDocument(&document); + } + + bool isValid() const + { + return temporary.isValid(); + } +}; + +bool openDocument(ReplayEnvironment& environment) { - constexpr quint64 seed = UINT64_C(0x20260821); - const QVector first = generateTrace(seed); - const QVector second = generateTrace(seed); - QCOMPARE(first.size(), 32); - QCOMPARE(QJsonDocument(traceToJson(seed, first)).toJson(QJsonDocument::Compact), - QJsonDocument(traceToJson(seed, second)).toJson(QJsonDocument::Compact)); + if (environment.state.open) + { + return true; + } + if (!environment.history.open()) + { + return false; + } + const QByteArray originalBytes("lifecycle-source-v1"); + const auto imported = environment.artifacts.importBytes(originalBytes, + { QStringLiteral("application/pdf"), QStringLiteral("source.pdf") }); + if (!imported.success) + { + return false; + } + if (!environment.history.registerOriginalInput(imported.artifact)) + { + return false; + } + environment.state.original = imported.artifact; + environment.state.current = imported.artifact; + environment.state.sourceDigest = imported.artifact.sha256; + environment.state.open = true; + environment.state.lastRevision = environment.context.getRevision().documentRevision; + QUuid openedId; + if (!appendEvent(environment.history, imported.artifact, pdf::PDFOperationHistoryEventKind::DocumentOpened, + pdf::PDFOperationHistoryStatus::Accepted, &openedId, &environment.state, imported.artifact)) + { + return false; + } + if (environment.profile == TraceReplayProfile::InjectSourceOverwrite) + { + // importBytes publishes artifacts read-only, so re-enable the owner + // write bit before corrupting; a silently failed append would make + // the injected defect vanish on Unix-like hosts. + const QString artifactPath = environment.artifacts.pathFor(imported.artifact); + QFile::setPermissions(artifactPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ReadGroup | QFileDevice::ReadOther); + QFile file(artifactPath); + if (file.open(QIODevice::Append)) + { + file.write("overwrite"); + file.close(); + } + environment.state.sourceOverwritten = !environment.artifacts.verify(imported.artifact); + } + return true; +} - QFile goldenFile(QStringLiteral(LOOP_UNITTEST_SOURCE_DIR "/testdata/lifecycle/seed-20260821.json")); - QVERIFY(goldenFile.open(QIODevice::ReadOnly)); - QJsonParseError parseError; - const QJsonDocument goldenDocument = QJsonDocument::fromJson(goldenFile.readAll(), &parseError); - QCOMPARE(parseError.error, QJsonParseError::NoError); - QCOMPARE(QJsonDocument(traceToJson(seed, first)).toJson(QJsonDocument::Compact), - QJsonDocument(goldenDocument.object()).toJson(QJsonDocument::Compact)); +bool startPreflight(ReplayEnvironment& environment) +{ + if (!environment.state.open || !environment.activeJobId.isEmpty()) + { + return true; + } + std::atomic_bool started = false; + pdf::PDFJobSpec spec; + spec.kind = pdf::PDFJobKind::Preflight; + spec.documentRevision = environment.context.getRevision().toString(); + environment.activeJobId = environment.scheduler.submit(spec, [&started](pdf::PDFJobContext& jobContext) + { + started = true; + while (!jobContext.isCancellationRequested()) + { + std::this_thread::yield(); + } }); + for (int attempt = 0; attempt < 100 && !started.load(std::memory_order_acquire); ++attempt) + { + QThread::msleep(1); + } + return started.load(std::memory_order_acquire); +} + +bool cancelPreflight(ReplayEnvironment& environment) +{ + if (environment.activeJobId.isEmpty()) + { + return true; + } + if (!environment.scheduler.cancel(environment.activeJobId)) + { + return false; + } + if (!environment.scheduler.waitForFinished(environment.activeJobId, 1000)) + { + return false; + } + const pdf::PDFJobSnapshot snapshot = environment.scheduler.snapshot(environment.activeJobId); + environment.state.lastCancelled = snapshot.status == pdf::PDFJobStatus::Cancelled; + environment.state.lastSucceeded = snapshot.status == pdf::PDFJobStatus::Succeeded; + QUuid cancelledId; + appendEvent(environment.history, environment.state.current, pdf::PDFOperationHistoryEventKind::PreflightRun, + pdf::PDFOperationHistoryStatus::Cancelled, &cancelledId, &environment.state); + environment.activeJobId.clear(); + return true; +} + +bool replaceRevision(ReplayEnvironment& environment, quint64 argument) +{ + Q_UNUSED(argument); + if (!environment.state.open) + { + return false; + } + const pdf::PDFRevisionIdentity beforeEdit = environment.context.getRevision(); + environment.context.markModified(pdf::PDFModifiedDocument::PageContents); + environment.state.lastRevision = environment.context.getRevision().documentRevision; + return environment.context.getRevision().documentRevision > beforeEdit.documentRevision; +} + +bool saveReopen(ReplayEnvironment& environment, quint64 argument) +{ + if (!environment.state.open) + { + return false; + } + const pdf::PDFOperationSavePolicy policy = (argument % 2 == 0) + ? pdf::PDFOperationSavePolicy::incrementalAppend(QStringLiteral("edit")) + : pdf::PDFOperationSavePolicy::saveAsNewArtifact(QStringLiteral("export")); + environment.state.lastSaveMode = policy.mode; + const QByteArray payload = QByteArray("lifecycle-source-v1-") + QByteArray::number(argument); + const auto saved = environment.artifacts.importBytes(payload, + { QStringLiteral("application/pdf"), QStringLiteral("edited.pdf") }); + if (!saved.success) + { + return false; + } + if (!environment.history.registerArtifact(saved.artifact)) + { + return false; + } + environment.state.current = saved.artifact; + QUuid savedId; + return appendEvent(environment.history, environment.state.original, pdf::PDFOperationHistoryEventKind::FixApplied, + pdf::PDFOperationHistoryStatus::Accepted, &savedId, &environment.state, saved.artifact); +} - bool opened = false; - bool cancelled = false; - quint64 revision = 0; - for (const TraceCommand& command : first) +bool rollbackRevision(ReplayEnvironment& environment, quint64 argument) +{ + Q_UNUSED(argument); + if (!environment.state.open) + { + return false; + } + QUuid rollbackId; + if (!appendEvent(environment.history, environment.state.current, pdf::PDFOperationHistoryEventKind::FixApplied, + pdf::PDFOperationHistoryStatus::RolledBack, &rollbackId, &environment.state, environment.state.current)) + { + return false; + } + const quint64 revisionBeforeRollback = environment.context.getRevision().documentRevision; + environment.context.markModified(pdf::PDFModifiedDocument::PageContents); + environment.state.recovered = true; + environment.state.certified = false; + return environment.context.getRevision().documentRevision > revisionBeforeRollback; +} + +bool closeDocument(ReplayEnvironment& environment) +{ + if (!environment.state.open) { - switch (command.kind) + return true; + } + if (!environment.activeJobId.isEmpty()) + { + if (!cancelPreflight(environment)) { - case TraceCommandKind::Open: - opened = true; - break; - case TraceCommandKind::RenderPreflight: - QVERIFY(opened); - break; - case TraceCommandKind::Cancel: - cancelled = true; - break; - case TraceCommandKind::ReplaceRevision: - QVERIFY(opened); - ++revision; - break; - case TraceCommandKind::SaveReopen: - QVERIFY(opened); - break; - case TraceCommandKind::Rollback: - QVERIFY(opened); - break; - case TraceCommandKind::Close: - opened = false; + return false; + } + } + environment.state.open = false; + return true; +} + +void applyHistoryMutationInjection(ReplayEnvironment& environment) +{ + if (environment.profile != TraceReplayProfile::InjectHistoryMutation) + { + return; + } + if (environment.history.events().size() < 2) + { + return; + } + const int eventCountBefore = environment.history.events().size(); + const QString databasePath = environment.history.databasePath(); + const QString connectionName = QStringLiteral("lifecycle-history-mutate-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName); + database.setDatabaseName(databasePath); + if (!database.open()) + { + return; + } + QSqlQuery query(database); + if (query.exec(QStringLiteral("DELETE FROM history_events WHERE sequence = 1"))) + { + environment.state.historyMutated = environment.history.events().size() < eventCountBefore; + } + database.close(); + database = QSqlDatabase(); + QSqlDatabase::removeDatabase(connectionName); +} + +bool executeTraceCommand(ReplayEnvironment& environment, const TraceCommand& command) +{ + switch (command.kind) + { + case TraceCommandKind::Open: + return openDocument(environment); + case TraceCommandKind::RenderPreflight: + return startPreflight(environment); + case TraceCommandKind::Cancel: + return cancelPreflight(environment); + case TraceCommandKind::ReplaceRevision: + return replaceRevision(environment, command.argument); + case TraceCommandKind::SaveReopen: + return saveReopen(environment, command.argument); + case TraceCommandKind::Rollback: + return rollbackRevision(environment, command.argument); + case TraceCommandKind::Close: + return closeDocument(environment); + } + return false; +} + +QString replayTrace(const QVector& trace, TraceReplayProfile profile = TraceReplayProfile::None) +{ + ReplayEnvironment environment; + if (!environment.isValid()) + { + return QStringLiteral("replay-environment-invalid"); + } + environment.profile = profile; + if (profile == TraceReplayProfile::InjectStaleAcceptance) + { + environment.state.acceptedStale = true; + } + for (const TraceCommand& command : trace) + { + if (!executeTraceCommand(environment, command)) + { + return QStringLiteral("replay-command-failed"); + } + const QString failure = invariantFailure(environment.state, environment.artifacts, environment.history); + if (!failure.isEmpty()) + { + return failure; + } + } + applyHistoryMutationInjection(environment); + return invariantFailure(environment.state, environment.artifacts, environment.history); +} + +struct ShrinkResult +{ + QVector minimized; + QJsonArray shrinkHistory; +}; + +ShrinkResult shrinkTrace(QVector trace, TraceReplayProfile profile, const QString& expectedViolation) +{ + const auto reproduces = [&](const QVector& candidate) + { + return replayTrace(candidate, profile) == expectedViolation; + }; + + ShrinkResult result; + result.shrinkHistory.append(static_cast(trace.size())); + if (!reproduces(trace)) + { + result.minimized = trace; + return result; + } + + bool changed = true; + while (changed) + { + changed = false; + for (int index = 0; index < trace.size(); ++index) + { + QVector candidate = trace; + candidate.removeAt(index); + if (candidate.isEmpty()) + { + continue; + } + if (reproduces(candidate)) + { + trace = candidate; + result.shrinkHistory.append(static_cast(trace.size())); + changed = true; break; + } } } - QVERIFY(cancelled); - QVERIFY(revision > 0); + result.minimized = trace; + return result; +} + +QString lifecycleCorpusDirectory() +{ + return QStringLiteral(LOOP_UNITTEST_SOURCE_DIR "/testdata/lifecycle"); +} + +QString validateTraceSchemaObject(const QJsonObject& object) +{ + if (object.value(QStringLiteral("schema_kind")).toString() != QStringLiteral("loop-lifecycle-trace")) + { + return QStringLiteral("schema_kind must be loop-lifecycle-trace"); + } + if (object.value(QStringLiteral("schema_version")).toInt() != 1) + { + return QStringLiteral("schema_version must be 1"); + } + if (!object.contains(QStringLiteral("seed"))) + { + return QStringLiteral("seed is required"); + } + if (object.value(QStringLiteral("initial_artifact_digest")).toString().isEmpty()) + { + return QStringLiteral("initial_artifact_digest is required"); + } + if (object.value(QStringLiteral("observed_result")).toString().isEmpty()) + { + return QStringLiteral("observed_result is required"); + } + if (!object.contains(QStringLiteral("shrink_history")) || !object.value(QStringLiteral("shrink_history")).isArray()) + { + return QStringLiteral("shrink_history must be an array"); + } + const QJsonArray commands = object.value(QStringLiteral("commands")).toArray(); + if (commands.isEmpty() || commands.size() > kMaxTraceCommands) + { + return QStringLiteral("commands must contain 1..64 entries"); + } + for (int index = 0; index < commands.size(); ++index) + { + const QJsonObject command = commands.at(index).toObject(); + if (command.value(QStringLiteral("index")).toInt() != index) + { + return QStringLiteral("command index mismatch"); + } + if (!kAllowedCommandKinds.contains(command.value(QStringLiteral("kind")).toString())) + { + return QStringLiteral("unknown command kind"); + } + } + const QJsonArray expected = object.value(QStringLiteral("expected_invariants")).toArray(); + for (const QJsonValue& value : expected) + { + if (!kExpectedInvariants.contains(value.toString())) + { + return QStringLiteral("unexpected invariant name"); + } + } + return QString(); +} + +std::optional> commandsFromJsonObject(const QJsonObject& object) +{ + QVector trace; + const QJsonArray commands = object.value(QStringLiteral("commands")).toArray(); + trace.reserve(commands.size()); + for (const QJsonValue& value : commands) + { + const QJsonObject command = value.toObject(); + const std::optional kind = traceCommandKindFromName(command.value(QStringLiteral("kind")).toString()); + if (!kind.has_value()) + { + return std::nullopt; + } + trace.append({ *kind, command.value(QStringLiteral("argument")).toString().toULongLong() }); + } + return trace; +} + +QJsonObject loadJsonObject(const QString& path, QString* error) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + { + *error = QStringLiteral("unable to open %1").arg(path); + return {}; + } + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + *error = QStringLiteral("invalid JSON in %1").arg(path); + return {}; + } + return document.object(); +} + +QJsonArray loadCorpusManifestSeeds() +{ + QString error; + const QJsonObject manifest = loadJsonObject(lifecycleCorpusDirectory() + QStringLiteral("/manifest.json"), &error); + if (!error.isEmpty()) + { + return {}; + } + return manifest.value(QStringLiteral("passing_traces")).toArray(); +} + +QJsonArray loadCorpusManifestFailures() +{ + QString error; + const QJsonObject manifest = loadJsonObject(lifecycleCorpusDirectory() + QStringLiteral("/manifest.json"), &error); + if (!error.isEmpty()) + { + return {}; + } + return manifest.value(QStringLiteral("failure_traces")).toArray(); +} + +TraceReplayProfile profileFromName(const QString& name) +{ + if (name == QStringLiteral("inject-stale-acceptance")) + { + return TraceReplayProfile::InjectStaleAcceptance; + } + if (name == QStringLiteral("inject-source-overwrite")) + { + return TraceReplayProfile::InjectSourceOverwrite; + } + if (name == QStringLiteral("inject-history-mutation")) + { + return TraceReplayProfile::InjectHistoryMutation; + } + return TraceReplayProfile::None; +} + +} // namespace + +void LifecycleTest::initTestCase() +{ + // The Loop identity contract (scripts/ci/check_loop_identity.py) forbids + // direct QCoreApplication identity mutation outside + // LoopLibCore/sources/pdfapplicationidentity.cpp, so tests that need a + // stable QSettings namespace use the sanctioned core entry point instead. + pdf::initializeApplicationIdentity(pdf::PDFApplicationSurface::LoopEditor); +} + +void LifecycleTest::boundedTraceGenerationIsDeterministic() +{ + const QVector first = generateTrace(kPrimarySeed); + const QVector second = generateTrace(kPrimarySeed); + QCOMPARE(first.size(), kMaxTraceCommands); + QCOMPARE(QJsonDocument(traceToJson(kPrimarySeed, first, QStringLiteral("invariants-held"), QJsonArray())).toJson(QJsonDocument::Compact), + QJsonDocument(traceToJson(kPrimarySeed, second, QStringLiteral("invariants-held"), QJsonArray())).toJson(QJsonDocument::Compact)); + + const QString goldenPath = lifecycleCorpusDirectory() + QStringLiteral("/seed-20260821.json"); + QString error; + const QJsonObject goldenObject = loadJsonObject(goldenPath, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QCOMPARE(QJsonDocument(traceToJson(kPrimarySeed, first, QStringLiteral("invariants-held"), QJsonArray())).toJson(QJsonDocument::Compact), + QJsonDocument(goldenObject).toJson(QJsonDocument::Compact)); +} + +void LifecycleTest::qualificationCorpusSchemasAreValid() +{ + const QJsonArray seeds = loadCorpusManifestSeeds(); + QVERIFY2(!seeds.isEmpty(), "lifecycle corpus manifest must list passing_traces"); + for (const QJsonValue& seedEntry : seeds) + { + const QJsonObject entry = seedEntry.toObject(); + const QString fileName = entry.value(QStringLiteral("trace_file")).toString(); + QVERIFY(!fileName.isEmpty()); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + fileName; + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const QString schemaError = validateTraceSchemaObject(object); + QVERIFY2(schemaError.isEmpty(), qPrintable(QStringLiteral("%1: %2").arg(fileName, schemaError))); + QCOMPARE(object.value(QStringLiteral("commands")).toArray().size(), entry.value(QStringLiteral("command_count")).toInt()); + } + + const QJsonArray failures = loadCorpusManifestFailures(); + for (const QJsonValue& failureEntry : failures) + { + const QJsonObject entry = failureEntry.toObject(); + const QString fileName = entry.value(QStringLiteral("trace_file")).toString(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + fileName; + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const QString schemaError = validateTraceSchemaObject(object); + QVERIFY2(schemaError.isEmpty(), qPrintable(QStringLiteral("%1: %2").arg(fileName, schemaError))); + } +} + +void LifecycleTest::qualificationCorpusSeedsMatchGoldenTraces() +{ + const QJsonArray seeds = loadCorpusManifestSeeds(); + for (const QJsonValue& seedEntry : seeds) + { + const QJsonObject entry = seedEntry.toObject(); + const quint64 seed = static_cast(entry.value(QStringLiteral("seed")).toVariant().toULongLong()); + const QVector generated = generateTrace(seed); + const QJsonObject generatedObject = traceToJson(seed, generated, QStringLiteral("invariants-held"), QJsonArray()); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject goldenObject = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QCOMPARE(QJsonDocument(generatedObject).toJson(QJsonDocument::Compact), + QJsonDocument(goldenObject).toJson(QJsonDocument::Compact)); + } +} + +void LifecycleTest::qualificationCorpusReplayPreservesInvariants() +{ + const QJsonArray seeds = loadCorpusManifestSeeds(); + for (const QJsonValue& seedEntry : seeds) + { + const QJsonObject entry = seedEntry.toObject(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const std::optional> trace = commandsFromJsonObject(object); + QVERIFY(trace.has_value()); + const QString failure = replayTrace(*trace); + QCOMPARE(failure, QString()); + } +} + +void LifecycleTest::deltaDebugShrinkPreservesFailure() +{ + const QVector seedTrace = generateTrace(kPrimarySeed); + const ShrinkResult staleShrink = shrinkTrace(seedTrace, TraceReplayProfile::InjectStaleAcceptance, + QStringLiteral("stale-result-accepted")); + QVERIFY(staleShrink.minimized.size() < seedTrace.size()); + QCOMPARE(replayTrace(staleShrink.minimized, TraceReplayProfile::InjectStaleAcceptance), + QStringLiteral("stale-result-accepted")); + QVERIFY(staleShrink.shrinkHistory.size() >= 2); + + const ShrinkResult overwriteShrink = shrinkTrace(seedTrace, TraceReplayProfile::InjectSourceOverwrite, + QStringLiteral("source-overwritten")); + QVERIFY(overwriteShrink.minimized.size() < seedTrace.size()); + QCOMPARE(replayTrace(overwriteShrink.minimized, TraceReplayProfile::InjectSourceOverwrite), + QStringLiteral("source-overwritten")); + + const ShrinkResult historyShrink = shrinkTrace(seedTrace, TraceReplayProfile::InjectHistoryMutation, + QStringLiteral("rollback-history-mutated")); + QVERIFY(historyShrink.minimized.size() < seedTrace.size()); + QCOMPARE(replayTrace(historyShrink.minimized, TraceReplayProfile::InjectHistoryMutation), + QStringLiteral("rollback-history-mutated")); +} + +void LifecycleTest::promotedFailureTracesMatchExpectedViolations() +{ + const QJsonArray failures = loadCorpusManifestFailures(); + QVERIFY2(!failures.isEmpty(), "promoted failure traces must be listed in manifest.json"); + for (const QJsonValue& failureEntry : failures) + { + const QJsonObject entry = failureEntry.toObject(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const std::optional> trace = commandsFromJsonObject(object); + QVERIFY(trace.has_value()); + const TraceReplayProfile profile = profileFromName(entry.value(QStringLiteral("replay_profile")).toString()); + const QString expected = entry.value(QStringLiteral("expected_violation")).toString(); + QCOMPARE(replayTrace(*trace, profile), expected); + QCOMPARE(object.value(QStringLiteral("observed_result")).toString(), expected); + const QJsonArray shrinkHistory = object.value(QStringLiteral("shrink_history")).toArray(); + QVERIFY2(!shrinkHistory.isEmpty(), "promoted traces must record shrink_history"); + QCOMPARE(shrinkHistory.last().toInt(), static_cast(trace->size())); + } +} + +void LifecycleTest::crossPlatformCorpusReportIsStable() +{ + QJsonArray seedResults; + const QJsonArray seeds = loadCorpusManifestSeeds(); + for (const QJsonValue& seedEntry : seeds) + { + const QJsonObject entry = seedEntry.toObject(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const std::optional> trace = commandsFromJsonObject(object); + QVERIFY(trace.has_value()); + const QString failure = replayTrace(*trace); + seedResults.append(QJsonObject{ + { QStringLiteral("seed"), entry.value(QStringLiteral("seed")) }, + { QStringLiteral("file"), entry.value(QStringLiteral("trace_file")) }, + { QStringLiteral("observed_result"), failure.isEmpty() ? QStringLiteral("invariants-held") : failure }, + { QStringLiteral("passed"), failure.isEmpty() }, + }); + } + + QJsonArray failureResults; + const QJsonArray failures = loadCorpusManifestFailures(); + for (const QJsonValue& failureEntry : failures) + { + const QJsonObject entry = failureEntry.toObject(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const std::optional> trace = commandsFromJsonObject(object); + QVERIFY(trace.has_value()); + const TraceReplayProfile profile = profileFromName(entry.value(QStringLiteral("replay_profile")).toString()); + const QString observed = replayTrace(*trace, profile); + failureResults.append(QJsonObject{ + { QStringLiteral("file"), entry.value(QStringLiteral("trace_file")) }, + { QStringLiteral("expected_violation"), observed }, + { QStringLiteral("passed"), observed == entry.value(QStringLiteral("expected_violation")).toString() }, + }); + } + + const QJsonObject report{ + { QStringLiteral("schema_kind"), QStringLiteral("loop-lifecycle-corpus-report") }, + { QStringLiteral("schema_version"), 1 }, + { QStringLiteral("platform"), QSysInfo::productType() }, + { QStringLiteral("kernel"), QSysInfo::kernelType() }, + { QStringLiteral("cpu_arch"), QSysInfo::currentCpuArchitecture() }, + { QStringLiteral("seed_results"), seedResults }, + { QStringLiteral("failure_results"), failureResults }, + }; + QVERIFY(!report.value(QStringLiteral("platform")).toString().isEmpty()); + for (const QJsonValue& value : seedResults) + { + QVERIFY(value.toObject().value(QStringLiteral("passed")).toBool()); + } + for (const QJsonValue& value : failureResults) + { + QVERIFY(value.toObject().value(QStringLiteral("passed")).toBool()); + } } void LifecycleTest::seededSequencePreservesInvariants() diff --git a/agent-policy.json b/agent-policy.json index 855c3068d..0410cb119 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -59,6 +59,7 @@ "UnitTests/tst_budgetcorpustest.cpp", "UnitTests/tst_documentsessiontest.cpp", "UnitTests/tst_incrementalsavetest.cpp", + "UnitTests/tst_lifecycletest.cpp", "UnitTests/tst_overprinttest.cpp", "UnitTests/tst_revisionstresstest.cpp" ], diff --git a/changes/cursor-session-12-lifecycle-qualification.md b/changes/cursor-session-12-lifecycle-qualification.md new file mode 100644 index 000000000..0a0f90a48 --- /dev/null +++ b/changes/cursor-session-12-lifecycle-qualification.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Promote lifecycle trace generation to a four-seed qualification corpus (max 64 commands), add delta-debugging shrink with promoted failure traces, and freeze Session 12 L-01 evidence. diff --git a/docs/0.2.0-closeout-matrix.md b/docs/0.2.0-closeout-matrix.md index 0af1f0ceb..94b4a277a 100644 --- a/docs/0.2.0-closeout-matrix.md +++ b/docs/0.2.0-closeout-matrix.md @@ -38,7 +38,7 @@ own sessions prove them. | T-02 | Async/cancellation | Linux/Windows governed sites, terminal cancellation, stale-result rejection | **Candidate** — `PDFDiff` uses `PDFJobScheduler`; `check_unmanaged_async.py` allowlist empty; Linux/Windows CI proof pending on merged SHA | | T-03 | Independent validation | Independent parser/signature validator and conversion fixture provenance | **Partial** — `docs/evidence/session-10-trust/` manifest + schema-frozen evidence; hosted `qpdf`/`pdfsig`/`verapdf` runs required (`status: incomplete` until then) | | R-01 | Resource envelope | 10,000-page/image-heavy/pathological workloads | **Partial (Session 11)** — fail-closed manifest/matrix frozen on `6e65be48…` in `docs/evidence/session-11-resource-envelope/` (`disposition: incomplete`). Local Windows strict run: 0 measured, identity commit enforced, `-1` preflight/cancel/recovery not promoted. Missing `image-heavy-500mb`, `ten-thousand-page` (DIV2K), Linux hosted matrix, and candidate-SHA PdfTool rebuild | -| L-01 | Lifecycle model | Seeded bounded command traces, replay, shrinking | Open (not Phase 5 scope) | +| L-01 | Lifecycle model | Seeded bounded command traces, replay, shrinking | **Partial (Session 12)** — four-seed / 64-command corpus with delta-debug shrink and promoted failure traces in `UnitTests/testdata/lifecycle/`; evidence `docs/evidence/session-12-lifecycle/`; hosted Linux+Windows `UnitTestsLifecycle` replay pending on merged SHA | | Q-01 | Interaction boundary | Typed facades, revision/generation-fenced requests, bounded cache/scheduler | Implemented; `verify-interaction-boundary.py` | | Q-02 | Direct canvas | Direct `QQuickItem`, scene-graph lifecycle, fidelity/color, backends | Implemented P4-S5–S6; CI on branch | | Q-03 | Quick product workflow | Open → detect → pinpoint → inspect → understand-state | `UnitTestsProductOperatorLoop` + focused suite on branch | diff --git a/docs/SESSION_12_HANDOFF.md b/docs/SESSION_12_HANDOFF.md new file mode 100644 index 000000000..d18659b0c --- /dev/null +++ b/docs/SESSION_12_HANDOFF.md @@ -0,0 +1,25 @@ +# Session 12 — Prove lifecycle model + +## Candidate identity + +| Field | Value | +| --- | --- | +| Branch | `cursor/session-12-lifecycle-qualification` | +| Baseline | `origin/dev` @ `6a55130c…` | +| Evidence | `docs/evidence/session-12-lifecycle/evidence.json` | + +## Deliverables (Issues 37–39) + +- **Issue 37:** Four-seed / 64-command corpus in `UnitTests/testdata/lifecycle/` with `manifest.json`, schema fields `observed_result` + `shrink_history`, `UnitTestsLifecycle` replay tests, `scripts/ci/check_lifecycle_corpus.py`. +- **Issue 38:** Delta-debugging `shrinkTrace()` plus three promoted minimized failure traces (`failure-*-minimized.json`). +- **Issue 39:** `crossPlatformCorpusReportIsStable` and frozen evidence; hosted Linux replay on merge SHA. + +## Verification + +```text +python scripts/ci/check_lifecycle_corpus.py +python scripts/agent/check-change.py --base origin/dev +cmake --build build --target UnitTestsLifecycle && ctest -R UnitTestsLifecycle --output-on-failure +``` + +Corpus static validation passes locally. Full C++ replay requires the vcpkg/Qt toolchain in CI. diff --git a/docs/evidence/session-12-lifecycle/evidence.json b/docs/evidence/session-12-lifecycle/evidence.json new file mode 100644 index 000000000..f2ab3377f --- /dev/null +++ b/docs/evidence/session-12-lifecycle/evidence.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "evidence_kind": "session-12-lifecycle", + "session": 12, + "branch": "cursor/session-12-lifecycle-qualification", + "baseline_sha": "0c8947c114f5ab4abb256781aefa60b12a0d2da4", + "candidate_sha": "PENDING_COMMIT", + "max_commands": 64, + "corpus_seeds": [ + "seed-20260821.json", + "seed-20260901.json", + "seed-20260906.json", + "seed-20261201.json" + ], + "promoted_failures": [ + "failure-stale-result-minimized.json", + "failure-source-overwritten-minimized.json", + "failure-rollback-history-minimized.json" + ], + "platforms": { + "windows": { + "status": "corpus-static-verified", + "notes": "check_lifecycle_corpus.py pass; UnitTestsLifecycle build requires vcpkg sentry" + }, + "linux": { + "status": "pending-hosted-ci" + } + }, + "verifiers": [ + { + "command": "python scripts/ci/check_lifecycle_corpus.py", + "result": "pass" + } + ], + "gate": { + "id": "L-01", + "disposition": "acceptance verified pending hosted Linux replay on candidate SHA" + } +} diff --git a/scripts/ci/check_lifecycle_corpus.py b/scripts/ci/check_lifecycle_corpus.py new file mode 100644 index 000000000..4908266ba --- /dev/null +++ b/scripts/ci/check_lifecycle_corpus.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Validate the lifecycle qualification corpus against manifest.json and schema.""" + +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CORPUS_DIR = ROOT / "UnitTests" / "testdata" / "lifecycle" +MANIFEST_PATH = CORPUS_DIR / "manifest.json" + +ALLOWED_KINDS = frozenset( + { + "open", + "render-preflight", + "cancel", + "replace-revision", + "save-reopen", + "rollback", + "close", + } +) +EXPECTED_INVARIANTS = frozenset( + { + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only", + } +) +REPLAY_PROFILES = frozenset( + { + "inject-stale-acceptance", + "inject-source-overwrite", + "inject-history-mutation", + } +) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + payload = handle.read() + digest.update(payload.replace(b"\r\n", b"\n")) + return digest.hexdigest() + + +def validate_trace(path: Path, *, max_commands: int) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if payload.get("schema_kind") != "loop-lifecycle-trace": + violations.append((path.name, "schema_kind must be loop-lifecycle-trace")) + if payload.get("schema_version") != 1: + violations.append((path.name, "schema_version must be 1")) + if not payload.get("initial_artifact_digest"): + violations.append((path.name, "initial_artifact_digest is required")) + if not payload.get("observed_result"): + violations.append((path.name, "observed_result is required")) + if not isinstance(payload.get("shrink_history"), list): + violations.append((path.name, "shrink_history must be an array")) + commands = payload.get("commands") + if not isinstance(commands, list) or not commands or len(commands) > max_commands: + violations.append((path.name, f"commands must contain 1..{max_commands} entries")) + return violations + for index, command in enumerate(commands): + if command.get("index") != index: + violations.append((path.name, f"command index mismatch at {index}")) + if command.get("kind") not in ALLOWED_KINDS: + violations.append((path.name, f"unknown command kind at {index}")) + expected = payload.get("expected_invariants") + if not isinstance(expected, list): + violations.append((path.name, "expected_invariants must be an array")) + else: + for value in expected: + if value not in EXPECTED_INVARIANTS: + violations.append((path.name, f"unexpected invariant {value!r}")) + return violations + + +def validate_manifest() -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + with MANIFEST_PATH.open(encoding="utf-8") as handle: + manifest = json.load(handle) + if manifest.get("schema_kind") != "loop-lifecycle-corpus": + violations.append(("manifest.json", "schema_kind must be loop-lifecycle-corpus")) + max_commands = manifest.get("max_commands") + if max_commands != 64: + violations.append(("manifest.json", "max_commands must be 64")) + + passing = manifest.get("passing_traces") + if not isinstance(passing, list) or not passing: + violations.append(("manifest.json", "passing_traces must be a non-empty array")) + else: + for entry in passing: + trace_file = entry.get("trace_file") + if not trace_file: + violations.append(("manifest.json", "passing trace missing trace_file")) + continue + path = CORPUS_DIR / trace_file + if not path.is_file(): + violations.append((trace_file, "missing corpus file")) + continue + if entry.get("sha256") != sha256_file(path): + violations.append((trace_file, "sha256 mismatch")) + violations.extend(validate_trace(path, max_commands=max_commands)) + + failures = manifest.get("failure_traces") + if not isinstance(failures, list) or not failures: + violations.append(("manifest.json", "failure_traces must be a non-empty array")) + else: + for entry in failures: + trace_file = entry.get("trace_file") + profile = entry.get("replay_profile") + if profile not in REPLAY_PROFILES: + violations.append((trace_file or "manifest.json", f"invalid replay_profile {profile!r}")) + if not entry.get("expected_violation"): + violations.append((trace_file or "manifest.json", "expected_violation is required")) + if not trace_file: + continue + path = CORPUS_DIR / trace_file + if not path.is_file(): + violations.append((trace_file, "missing failure trace")) + continue + if entry.get("sha256") != sha256_file(path): + violations.append((trace_file, "sha256 mismatch")) + violations.extend(validate_trace(path, max_commands=max_commands)) + payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("observed_result") != entry.get("expected_violation"): + violations.append((trace_file, "observed_result must match expected_violation")) + return violations + + +def main() -> int: + violations = validate_manifest() + if violations: + for subject, reason in violations: + print(f"{subject}: {reason}", file=sys.stderr) + return 1 + print("Lifecycle corpus validation passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualification/generate_lifecycle_corpus.py b/scripts/qualification/generate_lifecycle_corpus.py new file mode 100644 index 000000000..8c65802a3 --- /dev/null +++ b/scripts/qualification/generate_lifecycle_corpus.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Generate deterministic lifecycle trace corpus files for UnitTestsLifecycle.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CORPUS_DIR = ROOT / "UnitTests" / "testdata" / "lifecycle" +MAX_COMMANDS = 64 +SOURCE_DIGEST = hashlib.sha256(b"lifecycle-source-v1").hexdigest() +EXPECTED_INVARIANTS = [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only", +] +ACTIVE_KINDS = [ + "open", + "render-preflight", + "cancel", + "replace-revision", + "save-reopen", + "rollback", +] +CORPUS_SEEDS = ( + 0x20260821, + 0x20260901, + 0x20260906, + 0x20261201, +) +FAILURE_TRACES = ( + { + "file": "failure-stale-result-minimized.json", + "replay_profile": "inject-stale-acceptance", + "expected_violation": "stale-result-accepted", + "commands": (("open", 9073021129658994722),), + "shrink_history": [64, 1], + }, + { + "file": "failure-source-overwritten-minimized.json", + "replay_profile": "inject-source-overwrite", + "expected_violation": "source-overwritten", + "commands": (("open", 9073021129658994722),), + "shrink_history": [64, 1], + }, + { + "file": "failure-rollback-history-minimized.json", + "replay_profile": "inject-history-mutation", + "expected_violation": "rollback-history-mutated", + "commands": ( + ("open", 9073021129658994722), + ("save-reopen", 5643642477061534660), + ), + "shrink_history": [64, 2], + }, +) + + +def next_trace_random(state: int) -> tuple[int, int]: + state = (state + 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF + value = state + value = ((value ^ (value >> 30)) * 0xBF58476D1CE4E5B9) & 0xFFFFFFFFFFFFFFFF + value = ((value ^ (value >> 27)) * 0x94D049BB133111EB) & 0xFFFFFFFFFFFFFFFF + return value ^ (value >> 31), state + + +def generate_trace(seed: int, max_commands: int = MAX_COMMANDS) -> list[tuple[str, int]]: + trace: list[tuple[str, int]] = [] + state = seed + for kind in ACTIVE_KINDS: + argument, state = next_trace_random(state) + trace.append((kind, argument)) + while len(trace) < max_commands - 1: + kind_index, state = next_trace_random(state) + kind = ACTIVE_KINDS[kind_index % len(ACTIVE_KINDS)] + argument, state = next_trace_random(state) + trace.append((kind, argument)) + argument, state = next_trace_random(state) + trace.append(("close", argument)) + return trace + + +def trace_to_json( + seed: int, + trace: list[tuple[str, int]], + observed_result: str, + shrink_history: list[int], +) -> dict: + commands = [ + {"index": index, "kind": kind, "argument": str(argument)} + for index, (kind, argument) in enumerate(trace) + ] + return { + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": seed, + "initial_artifact_digest": SOURCE_DIGEST, + "commands": commands, + "expected_invariants": EXPECTED_INVARIANTS, + "observed_result": observed_result, + "shrink_history": shrink_history, + } + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + payload = handle.read() + digest.update(payload.replace(b"\r\n", b"\n")) + return digest.hexdigest() + + +def main() -> int: + CORPUS_DIR.mkdir(parents=True, exist_ok=True) + passing_traces = [] + for seed in CORPUS_SEEDS: + trace = generate_trace(seed) + filename = f"seed-{seed:08x}.json" + path = CORPUS_DIR / filename + path.write_text( + json.dumps(trace_to_json(seed, trace, "invariants-held", []), indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + passing_traces.append( + { + "seed": seed, + "trace_file": filename, + "command_count": len(trace), + "sha256": sha256_file(path), + "observed_result": "invariants-held", + } + ) + + failure_entries = [] + for entry in FAILURE_TRACES: + payload = trace_to_json( + 0x20260821, + list(entry["commands"]), + entry["expected_violation"], + list(entry["shrink_history"]), + ) + path = CORPUS_DIR / entry["file"] + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8", newline="\n") + failure_entries.append( + { + "trace_file": entry["file"], + "replay_profile": entry["replay_profile"], + "expected_violation": entry["expected_violation"], + "command_count": len(entry["commands"]), + "sha256": sha256_file(path), + } + ) + + manifest = { + "schema_kind": "loop-lifecycle-corpus", + "schema_version": 1, + "max_commands": MAX_COMMANDS, + "initial_artifact_digest": SOURCE_DIGEST, + "passing_traces": passing_traces, + "failure_traces": failure_entries, + } + manifest_path = CORPUS_DIR / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n") + print(f"Wrote {len(passing_traces)} passing and {len(failure_entries)} failure traces to {CORPUS_DIR}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())