diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index a44b271..41eb0e4 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -3667,4 +3667,13 @@ target_link_libraries(step531_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step532_test tests/step532_test.cpp) +target_include_directories(step532_test PRIVATE src) +target_link_libraries(step532_test PRIVATE + nlohmann_json::nlohmann_json + unofficial::tree-sitter::tree-sitter + tree_sitter_python tree_sitter_cpp tree_sitter_elisp + tree_sitter_javascript tree_sitter_typescript + tree_sitter_java tree_sitter_rust tree_sitter_go) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/RetryEscalationProtocol.h b/editor/src/RetryEscalationProtocol.h new file mode 100644 index 0000000..b9fb734 --- /dev/null +++ b/editor/src/RetryEscalationProtocol.h @@ -0,0 +1,129 @@ +#pragma once +// Step 532: Retry/Escalation Protocol for Constraint Failures + +#include +#include + +#include + +#include "ConstraintViolationDiagnostics.h" + +using json = nlohmann::json; + +struct RetryAttempt { + int attemptNumber = 0; + bool success = false; + std::vector appliedFixes; + ConstraintDiagnosticPacket diagnostics; +}; + +struct RetryEscalationInput { + ConstraintDiagnosticPacket initialDiagnostics; + int retryBudget = 2; +}; + +struct RetryEscalationResult { + bool resolved = false; + bool escalated = false; + int attemptsUsed = 0; + std::vector attempts; + ConstraintDiagnosticPacket finalDiagnostics; + json escalationPacket; +}; + +class RetryEscalationProtocol { +public: + static RetryEscalationResult run(const RetryEscalationInput& input) { + RetryEscalationResult result; + result.finalDiagnostics = input.initialDiagnostics; + + if (input.initialDiagnostics.ok) { + result.resolved = true; + return result; + } + + if (hasNonRetryable(input.initialDiagnostics)) { + result.escalated = true; + result.escalationPacket = makeEscalationPacket(input.initialDiagnostics, + result.attempts, + "non_retryable_violation"); + return result; + } + + int budget = input.retryBudget < 0 ? 0 : input.retryBudget; + ConstraintDiagnosticPacket current = input.initialDiagnostics; + + for (int attemptNo = 1; attemptNo <= budget; ++attemptNo) { + RetryAttempt attempt; + attempt.attemptNumber = attemptNo; + attempt.diagnostics = applyDeterministicRepair(current, attempt.appliedFixes); + attempt.success = attempt.diagnostics.ok; + result.attempts.push_back(attempt); + result.attemptsUsed = attemptNo; + current = attempt.diagnostics; + + if (current.ok) { + result.resolved = true; + result.finalDiagnostics = current; + return result; + } + } + + result.finalDiagnostics = current; + result.escalated = true; + result.escalationPacket = makeEscalationPacket(current, + result.attempts, + "retry_budget_exhausted"); + return result; + } + +private: + static bool hasNonRetryable(const ConstraintDiagnosticPacket& packet) { + for (const auto& v : packet.violations) { + if (!v.retryable) return true; + } + return false; + } + + static ConstraintDiagnosticPacket applyDeterministicRepair( + const ConstraintDiagnosticPacket& current, + std::vector& appliedFixes) { + ConstraintDiagnosticPacket next; + for (const auto& violation : current.violations) { + if (isAutoCorrectable(violation)) { + appliedFixes.push_back("drop-symbol:" + violation.field); + continue; + } + next.violations.push_back(violation); + } + + next.ok = next.violations.empty(); + next.recommendedAction = next.ok ? "proceed" + : (hasNonRetryable(next) ? "escalate" : "retry"); + return next; + } + + static bool isAutoCorrectable(const ConstraintViolation& violation) { + return violation.retryable && + (violation.code == "out_of_scope_symbol" || + violation.code == "transient_scope_miss"); + } + + static json makeEscalationPacket(const ConstraintDiagnosticPacket& finalDiag, + const std::vector& attempts, + const std::string& reason) { + json packet; + packet["reason"] = reason; + packet["finalDiagnostics"] = finalDiag.toJson(); + packet["attempts"] = json::array(); + for (const auto& a : attempts) { + packet["attempts"].push_back({ + {"attemptNumber", a.attemptNumber}, + {"success", a.success}, + {"appliedFixes", a.appliedFixes}, + {"diagnostics", a.diagnostics.toJson()} + }); + } + return packet; + } +}; diff --git a/editor/tests/step532_test.cpp b/editor/tests/step532_test.cpp new file mode 100644 index 0000000..e569361 --- /dev/null +++ b/editor/tests/step532_test.cpp @@ -0,0 +1,171 @@ +// Step 532: Retry/Escalation Protocol (12 tests) + +#include "RetryEscalationProtocol.h" + +#include + +static int passed = 0, failed = 0; +#define TEST(name) { std::cout << " " << #name << "... "; } +#define PASS() { std::cout << "PASS\n"; ++passed; } +#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; } +#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {} + +static ConstraintViolation mkViolation(const std::string& code, + const std::string& field, + bool retryable) { + return ConstraintViolation{code, code + " message", field, retryable}; +} + +static ConstraintDiagnosticPacket mkPacket( + const std::vector& violations) { + ConstraintDiagnosticPacket p; + p.violations = violations; + p.ok = violations.empty(); + p.recommendedAction = p.ok ? "proceed" : "escalate"; + return p; +} + +void test_ok_packet_resolves_without_retry() { + TEST(ok_packet_resolves_without_retry); + RetryEscalationInput input{mkPacket({}), 2}; + auto result = RetryEscalationProtocol::run(input); + CHECK(result.resolved, "ok packet should resolve"); + CHECK(!result.escalated, "ok packet should not escalate"); + CHECK(result.attemptsUsed == 0, "no attempts should be used"); + PASS(); +} + +void test_non_retryable_violation_escalates_immediately() { + TEST(non_retryable_violation_escalates_immediately); + RetryEscalationInput input{mkPacket({mkViolation("illegal_operation", "delete", false)}), 3}; + auto result = RetryEscalationProtocol::run(input); + CHECK(!result.resolved, "non-retryable should not resolve"); + CHECK(result.escalated, "non-retryable should escalate"); + CHECK(result.attemptsUsed == 0, "should not spend retry attempts"); + CHECK(result.escalationPacket["reason"] == "non_retryable_violation", "wrong escalation reason"); + PASS(); +} + +void test_retryable_out_of_scope_resolves_in_one_retry() { + TEST(retryable_out_of_scope_resolves_in_one_retry); + RetryEscalationInput input{mkPacket({mkViolation("out_of_scope_symbol", "ghost", true)}), 2}; + auto result = RetryEscalationProtocol::run(input); + CHECK(result.resolved, "retryable out-of-scope should resolve"); + CHECK(!result.escalated, "resolved packet should not escalate"); + CHECK(result.attemptsUsed == 1, "should resolve on first retry"); + PASS(); +} + +void test_mixed_retryable_and_non_retryable_escalates_without_retry() { + TEST(mixed_retryable_and_non_retryable_escalates_without_retry); + RetryEscalationInput input{mkPacket({ + mkViolation("out_of_scope_symbol", "ghost", true), + mkViolation("forbidden_symbol", "unsafeGlobal", false) + }), 2}; + auto result = RetryEscalationProtocol::run(input); + CHECK(result.escalated, "mixed packet should escalate"); + CHECK(result.attemptsUsed == 0, "mixed packet should not retry"); + PASS(); +} + +void test_retry_budget_exhaustion_escalates() { + TEST(retry_budget_exhaustion_escalates); + RetryEscalationInput input{mkPacket({mkViolation("retryable_unknown", "x", true)}), 2}; + auto result = RetryEscalationProtocol::run(input); + CHECK(!result.resolved, "unknown retryable should remain unresolved"); + CHECK(result.escalated, "budget exhaustion should escalate"); + CHECK(result.attemptsUsed == 2, "should consume full retry budget"); + CHECK(result.escalationPacket["reason"] == "retry_budget_exhausted", "wrong exhaustion reason"); + PASS(); +} + +void test_escalation_packet_contains_attempt_history() { + TEST(escalation_packet_contains_attempt_history); + RetryEscalationInput input{mkPacket({mkViolation("retryable_unknown", "x", true)}), 1}; + auto result = RetryEscalationProtocol::run(input); + CHECK(result.escalationPacket.contains("attempts"), "packet should contain attempts"); + CHECK(result.escalationPacket["attempts"].size() == 1, "attempt count should match retries"); + PASS(); +} + +void test_protocol_is_deterministic_for_same_input() { + TEST(protocol_is_deterministic_for_same_input); + RetryEscalationInput input{mkPacket({mkViolation("retryable_unknown", "x", true)}), 2}; + auto a = RetryEscalationProtocol::run(input); + auto b = RetryEscalationProtocol::run(input); + CHECK(a.attemptsUsed == b.attemptsUsed, "attempt counts should match"); + CHECK(a.escalated == b.escalated, "escalation decision should be deterministic"); + CHECK(a.finalDiagnostics.violations.size() == b.finalDiagnostics.violations.size(), + "final diagnostics should match"); + PASS(); +} + +void test_multiple_correctable_retryables_resolve_together() { + TEST(multiple_correctable_retryables_resolve_together); + RetryEscalationInput input{mkPacket({ + mkViolation("out_of_scope_symbol", "ghostA", true), + mkViolation("transient_scope_miss", "ghostB", true) + }), 2}; + auto result = RetryEscalationProtocol::run(input); + CHECK(result.resolved, "correctable retryables should resolve"); + CHECK(result.attemptsUsed == 1, "should resolve in one deterministic pass"); + PASS(); +} + +void test_final_diagnostics_retry_when_unresolved_retryable() { + TEST(final_diagnostics_retry_when_unresolved_retryable); + RetryEscalationInput input{mkPacket({mkViolation("retryable_unknown", "x", true)}), 1}; + auto result = RetryEscalationProtocol::run(input); + CHECK(!result.finalDiagnostics.ok, "final diagnostics should remain failing"); + CHECK(result.finalDiagnostics.recommendedAction == "retry", "final diagnostics should request retry"); + PASS(); +} + +void test_zero_retry_budget_escalates_for_retryable_failure() { + TEST(zero_retry_budget_escalates_for_retryable_failure); + RetryEscalationInput input{mkPacket({mkViolation("out_of_scope_symbol", "ghost", true)}), 0}; + auto result = RetryEscalationProtocol::run(input); + CHECK(result.escalated, "zero budget should escalate unresolved failure"); + CHECK(result.attemptsUsed == 0, "no retries should be used"); + PASS(); +} + +void test_attempt_records_applied_fixes_for_correctable_failure() { + TEST(attempt_records_applied_fixes_for_correctable_failure); + RetryEscalationInput input{mkPacket({mkViolation("out_of_scope_symbol", "ghost", true)}), 2}; + auto result = RetryEscalationProtocol::run(input); + CHECK(!result.attempts.empty(), "attempt history expected"); + CHECK(result.attempts[0].appliedFixes.size() == 1, "one fix should be recorded"); + CHECK(result.attempts[0].appliedFixes[0] == "drop-symbol:ghost", "fix metadata mismatch"); + PASS(); +} + +void test_escalation_packet_contains_final_diagnostics() { + TEST(escalation_packet_contains_final_diagnostics); + RetryEscalationInput input{mkPacket({mkViolation("retryable_unknown", "x", true)}), 1}; + auto result = RetryEscalationProtocol::run(input); + CHECK(result.escalationPacket.contains("finalDiagnostics"), "packet should include final diagnostics"); + CHECK(result.escalationPacket["finalDiagnostics"]["ok"] == false, + "final diagnostics in packet should indicate failure"); + PASS(); +} + +int main() { + std::cout << "Step 532: Retry/Escalation Protocol\n"; + + test_ok_packet_resolves_without_retry(); // 1 + test_non_retryable_violation_escalates_immediately(); // 2 + test_retryable_out_of_scope_resolves_in_one_retry(); // 3 + test_mixed_retryable_and_non_retryable_escalates_without_retry(); // 4 + test_retry_budget_exhaustion_escalates(); // 5 + test_escalation_packet_contains_attempt_history(); // 6 + test_protocol_is_deterministic_for_same_input(); // 7 + test_multiple_correctable_retryables_resolve_together(); // 8 + test_final_diagnostics_retry_when_unresolved_retryable(); // 9 + test_zero_retry_budget_escalates_for_retryable_failure(); // 10 + test_attempt_records_applied_fixes_for_correctable_failure(); // 11 + test_escalation_packet_contains_final_diagnostics(); // 12 + + std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index da6ac59..bdbc83f 100644 --- a/progress.md +++ b/progress.md @@ -8851,3 +8851,41 @@ post-apply contract expectations. - `editor/src/ContractDeltaChecker.h` within header-size limit (`149` <= `600`) - `editor/tests/step531_test.cpp` within test-file size guidance (`200` lines) - Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md` + +### Step 532: Retry/Escalation Protocol for Constraint Failures +**Status:** PASS (12/12 tests) + +Implements deterministic retry/escalation behavior for constraint failures, +including automatic correction for retryable scope failures and full failure +packet escalation when non-retryable or unresolved conditions remain. + +**Files added:** +- `editor/src/RetryEscalationProtocol.h` - retry/escalation module: + - immediate pass-through for already-valid diagnostics + - immediate escalation for non-retryable violations + - deterministic repair pass for correctable retryable failures + - retry-budget control with attempt history + - machine-readable escalation packet with reason/final diagnostics/attempts +- `editor/tests/step532_test.cpp` - 12 tests covering: + - resolved/no-retry path + - immediate escalation for non-retryable and mixed failures + - successful retry for correctable failures + - retry-budget exhaustion behavior + - escalation packet structure and attempt history + - deterministic repeatability guarantees + - zero-budget edge case + - applied-fix metadata capture + +**Files modified:** +- `editor/CMakeLists.txt` - `step532_test` target + +**Verification run:** +- `cmake -S editor -B editor/build-native` - PASS +- `cmake --build editor/build-native --target step532_test step531_test` - PASS +- `./editor/build-native/step532_test` - PASS (12/12) +- `./editor/build-native/step531_test` - PASS (12/12) regression coverage + +**Architecture gate check:** +- `editor/src/RetryEscalationProtocol.h` within header-size limit (`129` <= `600`) +- `editor/tests/step532_test.cpp` within test-file size guidance (`171` lines) +- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`