An autoresearch loop that refactored getRevenue
A keep-or-discard harness drove an LLM agent through a recursive-to-iterative refactor of a 1,400-line revenue method — gated on a 2,400-cell byte-identical golden and a strict complexity metric.
apps/server/autoresearch/getRevenue/ — steering brief, scalar fitness eval, LLM judge, keep/discard loop, and a 2,400-cell byte-identical golden snapshot.
PricingEngineService.getRevenue: recursion eliminated, strict cyclomatic complexity dropped from the high-30s/50s to 8, duplicated leaf math collapsed, side-effects lifted to the boundary.
tsc, so behavior is preserved by construction.
1. The shape of the problem
PricingEngineService.getRevenue sat at ~1,700 lines in pricingEngineService.ts, hand-tuned over years. It was the central revenue method, called from ~149 sites, and both the all and category selectors summed their products by calling this.getRevenue({ type: 'product', … }) inside a reduce — so it called itself once per product.
all → loops products → getRevenue(product)category → loops products → getRevenue(product)product = base case (the actual math)
all path), the ramped annually-billed /12 special case, the year-1-only zeroing, monthly-minimum with vs without a month index. One displaced number breaks billing.
cost strictly drops. Progress accumulates. The agent physically cannot edit its own gate.
2. The harness, in pieces
Six files under apps/server/autoresearch/getRevenue/ form the fixed harness — the analogue of Karpathy's prepare.py. The loop is never allowed to edit them.
| File | Role | Size |
|---|---|---|
program.md | steering brief — objective, hard constraints, parity checklist | +137 |
eval.ts | scalar fitness cost — gates + AST metrics + judge | +545 |
judge.ts | LLM code-quality judge (0–4, per-dimension, actionable feedback) | +377 |
loop.sh | keep/discard driver, threads feedback into next candidate | +261 |
__test__/getRevenueGolden.spec.ts | 2,400-cell byte-identical snapshot of original behavior | +751 |
__test__/getRevenueGolden.json | frozen recorded values | +2402 |
The cost function, phased
The loop minimizes a single scalar cost (lower = better) and reverts any round that doesn't strictly lower it. Three phases, each with a different cost shape, so progress can be banked incrementally rather than waiting for one heroic edit:
| Phase | cost (lower = better) | Targets met when |
|---|---|---|
| 1 — shrink + lift | max CC | max CC ≤ CC_TARGET (recursion allowed) |
| 2 — de-recurse | recursion × 1000 + CC | 0 recursion AND CC ≤ target |
| 3 — polish | favours judge score | judge ≥ TARGET_SCORE, with min scores on the functional dimensions |
recursion × 1000 into the cost so partial de-recursion (3 → 2 → 1 → 0 functions on the cycle) is also banked.
3. How a round actually runs
Step through one full iteration of the loop. The orange node is what's happening now; green means done, purple means upcoming.
program.md (the steering brief — objective, hard constraints, parity checklist) and the previous round's eval-result.json feedback. The brief asks for ONE small change — extract a helper, collapse a duplication, or lift a side-effect — not the whole refactor.
4. The cheating-prevention layer
The harness gives a code-gen agent root access to the repo. Three concrete defenses keep the loop honest:
enforce_file_set diffs git status against the user's pre-existing dirty files and the two allowed paths (pricingEngineService.ts, pricingEngineRevenue.ts). Anything else — including the golden JSON or any harness file — is checked out from HEAD. The agent can't weaken its own gate.
STRICT_CC=1 (default) the metric folds nested closures (.map/.reduce callbacks, IIFEs) into the enclosing function. getRevenue is ~16 per-function but ~32 strict. Complexity can't hide in a callback.
getRevenue → helper → getRevenue still counts. Routing recursion through an extracted helper doesn't lower the score; a renamed monolith doesn't either.
getRevenue + every function the refactor adds, measured against git merge-base HEAD origin/main — not HEAD. So a newly extracted helper keeps counting even after it's committed.
5. The refactor it produced
Running the loop produced the actual getRevenue rewrite, landed as commits on top of the harness. Three phases, each with measurable wins:
Before / after — the call shape
getRevenue({type:'all'})
→ products.reduce((sum, p) =>
sum + getRevenue({type:'product', p}))
↑ recurses
getRevenue({type:'category'})
→ products.reduce((sum, p) =>
sum + getRevenue({type:'product', p}))
↑ recurses
getRevenue({type:'product'}) // base case
→ ~1400 lines of inlined leaf math
+ impure reads buried inside
getRevenue(selector, tp, …)
→ dispatch on selector.type
aggregateProductRevenue(products, applyAcv)
→ products.reduce((sum, p) =>
sum + productSelectorRevenue(p, …))
↑ calls leaf directly
productSelectorRevenue(p, …) // pure-ish leaf
→ resolveProductCase → computeAbsoluteRevenue
│ productMonthIdxRevenue
│ computeRecurringMonthlyUnitlessRevenue
Before / after — files
getTerm, evaluationEngine) at the boundary, threads them into pure leaves.
readonly params, no this, no logging. Holds the percent-volume math, the bps-per-txn clamp context, and the time-period predicates.
The new pieces
aggregateProductRevenue— folds product lists, applies ACV only on yearlyallproductSelectorRevenue— extracted single-productbase caseresolveProductCase— guards + boundaryavgTransactionSizereadresolveProductRevenueContext— per-product invariants resolved oncecomputeAbsoluteRevenue—total_contract/year_idxas explicit foldscomputeRecurringMonthlyUnitlessRevenue— shared flat+percent assembly
isYearlyRevenueTimePeriod,isZeroForYear1Only— predicates, lifted verbatimapplyAvgTxnSizeMultiply— the multiply leaf (with thebpsPercentPricing → 0precedence preserved)buildBpsPerTxnContext+bpsPerTxnArg— Plaid bps clamp, resolved oncecomputeMonthIdxPercentVolume,computeRecurringMonthlyPercentVolume— kept separate on purpose (different ramp-flatten strategies)
6. The golden net
What lets the loop edit aggressively without losing correctness. Generated once against the original recursive code and frozen — each fixture's recorded value was hand-verified to confirm the branch actually executes.
percentWithLimits clamp, bpsPercentPricing per-txn clamp, avgTransactionSize multiply, ramped volume (all five flatten strategies), ramped price + annually-billed /12 special case, tiered percent, the three divisor sources, monthly minimum, ACV adjustment, multi-year fractional proration.
REGEN_GOLDEN. One mismatched cent fails the gate. Every kept round had to be byte-identical across all 2,400 cells and pass tsc. Behavior is preserved by construction, not by inspection.
Show sample golden cells
"flat_monthly_12mo | all | abs:total_contract | plain": 7200,
"acvAdjustment_12mo | all | abs:total_contract | plain": 11500,
"acvAdjustment_12mo | all | abs:year_idx:1 | plain": -500,
"monthlyMinimum_flat_12mo | all | abs:month_idx:0 | plain": 333.33333333333326,
"monthlyMinimum_plaidRamp_12mo | all | abs:month_idx:0 | plain": 300,
"freqAnnually_24mo | all | abs:month_idx:0 | plain": 3600,
"freqFixedMonths_ramped_12mo | all | abs:month_idx:0 | plain": 300,
"customDates_18mo | all | abs:total_contract | prorate": 6000,
"oneTime_fee_12mo | all | abs:month_idx:0 | plain": 416.6666666666667,
7. What it doesn't change
- Public signature.
getRevenue(selector, timePeriod, prorateOptions?, skipMonthlyMinimumDiscount?)— ~149 call sites untouched. - Summation order. Products in filtered order, months in helper order, years
0..N. Float+is non-associative; reordering would break byte-identity. - The three divisor sources. Annually uses
getTimePeriodDivisor(no flag); monthly uses{checkPercentVolume:true}; month_idx usesgetRevenueTimePeriodDivisor. They are NOT unified. - The ACV adjustment. Applied only on the
allyearly path; never oncategoryorproduct. - The ramped annually-billed
/12special case. Still bypasses divisor and mask. - The pre-existing recursive helpers (tier-walk
getListPrice/getCost). Out of scope; the loop's recursion check explicitly allows them. - Other tests. The full Jest suite + lint run on CI; the loop itself gates each round on typecheck + the golden only (for speed).
8. Open questions and follow-ups
immutability, performance (per-product-month memoization), and less_duplication at 2/4 — real polish opportunities, not required for correctness. They can be picked up by another phase-3 run with more iterations.
RUN_MOCHA=1 enables it; default is the golden-only Jest gate for loop speed. Both run on CI for the final merge.
tsc/jest/mocha/tsx binaries directly rather than via pnpm run. Pnpm's pre-run dependency check can abort on an unrelated CDN-pinned dependency (xlsx) when the network is restricted; the direct binary calls sidestep that gate.
9. How to run it
# From apps/server. Golden is already committed — no regen needed.
PHASE=1 CC_TARGET=10 GEN_TIMEOUT=240 ITERATIONS=24 \
JUDGE_PROVIDER=anthropic ANTHROPIC_API_KEY=... JUDGE_MODEL=<model> \
bash autoresearch/getRevenue/loop.sh
# …then PHASE=2, then PHASE=3.
# Or just score the current end state:
JUDGE_PROVIDER=anthropic ANTHROPIC_API_KEY=... JUDGE_MODEL=<model> \
pnpm run eval:getRevenue