Skip to content

feat(ladder): truncate over-cap plans instead of erroring (L1) - #1366

Merged
cristim merged 3 commits into
mainfrom
feat/ladder-truncate-actions
Jul 16, 2026
Merged

feat(ladder): truncate over-cap plans instead of erroring (L1)#1366
cristim merged 3 commits into
mainfrom
feat/ladder-truncate-actions

Conversation

@cristim

@cristim cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member

What

Work item L1 of the auto-laddering full-automation plan (closes gaps B3 / guardrail-stall). Changes the MaxActionsPerRun guardrail in the ladder allocation engine from error to deterministic truncation.

Today, when a plan's action count exceeds cfg.MaxActionsPerRun, Allocate returns an error (pkg/ladder/allocate.go). Unattended, an over-cap config then errors on every run forever: a guardrail that behaves as a permanent stall. This truncates the plan instead, deferring the excess to a later run.

Behavior (per the locked Q-TRUNC decision)

  • Reshapes are retained (risk-reducing, no new spend).
  • Among purchase allocations, largest GapUSDPerHour first is kept; the smallest are dropped until the cap is reached.
  • Every dropped money action emits an ActionHold with a max_actions_per_run rationale naming the layer and amount: nothing vanishes untraced (no silent money-action loss).
  • Deterministic: stable sort by gap with explicit layer tie-breakers; big.Rat comparison (not float); Holds excluded from the cap count.

Verification (exit 0)

  • go build ./..., go vet ./... clean; pkg go test ./ladder/... 249 pass; gocyclo -over 10 clean.
  • Regression tests (fail-before / pass-after confirmed): over-cap purchases truncate to the largest-gap set with Holds for the rest; a reshape filling the cap still emits Holds for every dropped purchase (this exact silent-drop path was caught in review and is now tested); exactly-at-cap is untouched. Assertions are on the surviving/held set, not just counts.

Notes

Fresh-eyes review caught a CRITICAL edge in the first cut (a reshape filling the cap dropped all purchases with no audit trace); it is fixed and regression-tested. The reshapes-exceed-cap fallback is production-unreachable today (buffer layers capped at 1, MaxActionsPerRun > 0 validated) and documented as defensive-only.

Part of #1336. Tracker #1333.

Summary by CodeRabbit

  • New Features

    • Allocation runs exceeding the action limit now complete with deterministic truncation instead of failing.
    • Reshapes are prioritized over purchases, while purchases with the largest funding gaps are retained first.
    • Dropped actions generate auditable hold entries explaining the deferral and action limit.
  • Documentation

    • Updated Allocate step guidance to describe truncation behavior.
  • Tests

    • Added coverage for mixed actions, exact limits, prioritization, and audit holds.

@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/medium Moderate harm urgency/this-quarter Within the quarter impact/many Affects most users effort/s Hours type/feat New capability labels Jul 16, 2026
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 69fc6ac1-2479-41d9-928f-02f2cf71815e

📥 Commits

Reviewing files that changed from the base of the PR and between 5ed812a and b7bc832.

📒 Files selected for processing (3)
  • internal/server/handler_ladder_test.go
  • pkg/ladder/allocate.go
  • pkg/ladder/allocate_test.go
📝 Walkthrough

Walkthrough

Changes

The Allocate pipeline now deterministically truncates actions exceeding MaxActionsPerRun, prioritizing reshapes and largest-gap purchases while producing audit holds for dropped actions. Tests cover mixed actions, reshape saturation, and exact-cap behavior.

Action truncation

Layer / File(s) Summary
Implement deterministic action truncation
pkg/ladder/allocate.go
buildResult applies MaxActionsPerRun, retains urgent reshapes and largest-gap purchases, and emits ActionHold entries for dropped actions.
Validate truncation outcomes
pkg/ladder/allocate_test.go
Regression tests verify purchase ranking, reshape retention, reshape-only cap saturation, audit hold details, and unchanged exact-cap results.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Allocate
  participant buildResult
  participant truncateToMaxActions
  participant AllocateResult
  Allocate->>buildResult: build allocation result
  buildResult->>truncateToMaxActions: apply MaxActionsPerRun
  truncateToMaxActions-->>buildResult: retained actions and truncation holds
  buildResult->>AllocateResult: assemble actions and holds
Loading

Possibly related PRs

  • LeanerCloud/CUDly#1340: Previous handling of MaxActionsPerRun in allocate.go returned an error instead of truncating actions.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the core change: over-cap ladder plans are now truncated deterministically instead of erroring.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ladder-truncate-actions

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
pkg/ladder/allocate_test.go (1)

594-607: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the equal-gap layer tie-breaker.

The current equal-gap allocations are both dropped, so no test verifies which layer survives when they compete for one slot. Add a case with equal GapUSDPerHour values and purchaseCap=1, asserting the layer-name ordering.

Also applies to: 686-699

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ladder/allocate_test.go` around lines 594 - 607, Add a test case in the
allocation test near the existing gap-priority assertions that creates two
layers with equal GapUSDPerHour and purchaseCap=1, then verify exactly one
allocation remains and it is selected according to the expected layer-name
ordering. Ensure the assertion covers both the surviving and dropped tied
layers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@pkg/ladder/allocate_test.go`:
- Around line 594-607: Add a test case in the allocation test near the existing
gap-priority assertions that creates two layers with equal GapUSDPerHour and
purchaseCap=1, then verify exactly one allocation remains and it is selected
according to the expected layer-name ordering. Ensure the assertion covers both
the surviving and dropped tied layers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4dad105d-fad8-4627-95d5-458f77ec4931

📥 Commits

Reviewing files that changed from the base of the PR and between 79642c4 and 5ed812a.

📒 Files selected for processing (2)
  • pkg/ladder/allocate.go
  • pkg/ladder/allocate_test.go

cristim added 3 commits July 16, 2026 16:06
Replace the permanent-stall error when a plan exceeds MaxActionsPerRun with
deterministic truncation (B3 / Q-TRUNC). Reshapes are always retained first
(risk-reducing, no new spend). Among purchase allocations the largest
GapUSDPerHour are kept; the smallest are dropped. Every dropped action emits
an ActionHold with a "deferred to a later run: max_actions_per_run=N reached"
rationale so no money action is silently lost. Ties broken by layer name for
a stable ordering.

Implementation extracts truncateReshapes and truncatePurchases helpers to
keep cyclomatic complexity under the project limit (gocyclo -over 10 clean).
…l the cap

When reshapes alone fill or exceed MaxActionsPerRun, truncateToMaxActions
previously returned nil allocations while emitting holds only for dropped
reshapes, so every purchase vanished with zero audit trace. This violated the
Q-TRUNC rule that every dropped money action must leave a Hold. That branch now
converts all allocations to holds (truncatePurchases with purchaseCap 0),
naming each dropped layer and amount.

Also: document the branch invariant (unreachable in production: at most one
buffer reshape and MaxActionsPerRun > 0), rename the shadowing max parameter to
maxActions in the truncate helpers, and tighten tests to set-level assertions
(exact surviving/held layers). Adds TestAllocate_MaxActions_ReshapesFillCap as
the regression for the dropped-purchase-hold gap.
…contract

PR #1362 added TestExecuteLadderRun_MaxActionsExceeded asserting the old
engine contract (Allocate errors when the split exceeds MaxActionsPerRun).
This branch changes that contract to deterministic truncation, so it owns
the consumer-test update.

Renamed to TestExecuteLadderRun_MaxActionsExceeded_TruncatesAndPersists and
rewrote it to the new contract: executeLadderRun succeeds, persists a
status=planned run whose plan contains exactly one surviving purchase (the
larger-gap $70/hr flex allocation) plus a max_actions_per_run hold for the
dropped $10/hr base allocation, TotalHourlyCommit reflects only the kept
allocation, and tranches exist for it. The fixture ramp moves to a single
future step (AfterDays=30) since an AfterDays=0 step becomes a BuyNow action
and produces no tranche rows.
@cristim
cristim force-pushed the feat/ladder-truncate-actions branch from 5ed812a to b7bc832 Compare July 16, 2026 13:13
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Rebased onto post-#1362 main (7adacd7); no textual conflicts.

Updated the MaxActionsExceeded handler test to the new truncate contract: this PR changes the engine contract (Allocate now truncates at max_actions_per_run instead of erroring), so it owns the consumer-test update. TestExecuteLadderRun_MaxActionsExceeded (added by #1362, asserting the old fail-loud behavior) is now TestExecuteLadderRun_MaxActionsExceeded_TruncatesAndPersists: executeLadderRun succeeds, persists a status=planned run with exactly one surviving purchase (the larger-gap $70/hr flex allocation), a max_actions_per_run hold for the dropped $10/hr base allocation, TotalHourlyCommit reflecting only the kept allocation, and tranches for it.

Gates green: go build ./... (0), go vet ./... (0), pkg ladder tests 249 passed (0), internal/server 401 passed (0).

@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 157f9e2 into main Jul 16, 2026
14 of 17 checks passed
@cristim
cristim deleted the feat/ladder-truncate-actions branch July 16, 2026 13:43
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Merged to main after: Fable review (BLOCK->SHIP across two rounds, including a caught-and-fixed CRITICAL silent purchase-drop when a reshape fills the action cap), CodeRabbit clean, rebase onto post-#1362 main with the MaxActionsExceeded handler test rewritten to the new truncate contract (this PR changed the engine contract so it owned the consumer-test update), and full functional CI green. MaxActionsPerRun now truncates deterministically (largest gap kept, reshapes retained, every dropped action leaves an audit Hold) instead of stalling the config forever. Unblocks L5 (in-flight tranche netting) in the automation chain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p1 Next up; this sprint severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant