Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
573 changes: 573 additions & 0 deletions docs/brutal-remediation-backlog.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/config-snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,7 @@ export default nextConfig;
"typescript": "^5"
},
"engines": {
"node": ">=24.15.0 <25"
"node": ">=24.14.1 <25"
}
},
"node_modules/@alloc/quick-lru": {
Expand Down Expand Up @@ -9583,7 +9583,7 @@ export default nextConfig;
"private": true,
"type": "module",
"engines": {
"node": ">=24.15.0 <25"
"node": ">=24.14.1 <25"
},
"engineStrict": true,
"packageManager": "npm@11.12.1",
Expand Down
5 changes: 4 additions & 1 deletion docs/engine-optimality/candidate-space.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
Status: Active
Last updated: 2026-03-19
Last updated: 2026-04-28

# Engine Optimality Candidate Space

## Current behavior

Cherry evaluates a bounded generated candidate set with deterministic heuristic ranking. It does not prove global optimality over all possible financial actions.

### Candidate space R(B) (bounded)

`R(B)` is the representable candidate set defined purely by the bounds axes for
Expand Down Expand Up @@ -41,6 +43,7 @@ Each action type has explicit parameter axes bounded by `Bounds`:
- The live solver is not a future scheduler.
- Live-generated `PAY_DOWN_DEBT` and `USE_CARD_WITH_PAYDOWN` actions are immediate-only and single-step.
- `USE_CARD_WITH_PAYDOWN` is ordered as: purchase authorization effect, then immediate paydown effect.
- `maxCandidates`, when provided to the live solver, caps returned and traced ranked candidates only after deterministic filtering and ranking; it does not prune generated candidates before evaluation.

### Completeness Lemma (Bounded)

Expand Down
9 changes: 8 additions & 1 deletion docs/engine-optimality/status.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
Status: Active
Last updated: 2026-01-18
Last updated: 2026-04-28

# Engine Optimality Status

## Current behavior

Cherry evaluates a bounded generated candidate set with deterministic heuristic ranking. It does not prove global optimality over all possible financial actions.

### Proven (bounded)

- Bounded exact optimality is proven for `(objective_v1, candidates_v1)` under
Expand All @@ -18,6 +20,11 @@ Last updated: 2026-01-18
- Real-world preference correctness or reward accuracy.
- Completeness outside the tested bounds **B**.

### Live solver surface cap

- `maxCandidates`, when provided, caps the surfaced ranked candidates in returned decisions and trace output.
- `maxCandidates` does not cap the evaluated candidate set before scoring.

### Trace schema

- `docs/engine-optimality/trace.md`
Expand Down
20 changes: 9 additions & 11 deletions lib/engine/solver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,8 @@ export async function solveDecision(
options.candidateFilter != null
? candidateActions.filter((action) => options.candidateFilter?.(action) === true)
: candidateActions;
// PR8.3 intentionally counts exclusions from the surface-filtered generated set before
// hard filtering and before score sorting. This is a temporary coupling to pre-PR9
// truncation behavior; PR9 may revise evaluation-order semantics.
// Count exclusions from the full surface-filtered generated set before hard filtering
// and ranking. maxCandidates caps surfaced ranked output only, not evaluation.
const exclusions = surfaceFilteredCandidates.reduce<EngineExclusions>((acc, action) => {
if (!actionRequiresResolvableCreditLiability(action)) {
return acc;
Expand All @@ -151,15 +150,10 @@ export async function solveDecision(
!Number.isNaN(options.maxCandidates)
? options.maxCandidates
: null;
const constrainedCandidates =
maxCandidates !== null && surfaceFilteredCandidates.length > maxCandidates
? surfaceFilteredCandidates.slice(0, maxCandidates)
: surfaceFilteredCandidates;

const decisions: EngineDecision[] = [];
const hardConstraints = getHardConstraints(state);

for (const action of constrainedCandidates) {
for (const action of surfaceFilteredCandidates) {
const projections = simulateAction(state, ctx, action, { scheduledPaydownEvaluation });
const { score, reasons, components } = scoreDecision(state, ctx, action, projections, weights);
const constraintTags = evaluateConstraintsForDecision(state, ctx, action, projections);
Expand All @@ -184,6 +178,10 @@ export async function solveDecision(
if (primary !== 0) return primary;
return a.actionId.localeCompare(b.actionId);
});
const surfaced =
maxCandidates !== null && filtered.length > maxCandidates
? filtered.slice(0, maxCandidates)
: filtered;

const trace: EngineDecisionTrace = {
engineVersion: ENGINE_VERSION,
Expand All @@ -198,7 +196,7 @@ export async function solveDecision(
merchantCategoryKey: ctx.merchantCategoryKey == null ? null : ctx.merchantCategoryKey,
amountCents: ctx.amountCents == null ? null : ctx.amountCents,
},
candidates: filtered.map((d) => ({
candidates: surfaced.map((d) => ({
action: d.action,
score: d.score,
constraintsBreached: d.constraintsBreached,
Expand Down Expand Up @@ -229,7 +227,7 @@ export async function solveDecision(
}

const result: SolveDecisionResult = {
decisions: filtered,
decisions: surfaced,
trace,
exclusions,
capabilities,
Expand Down
2 changes: 1 addition & 1 deletion lib/engine/version.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const engineBehaviorVersion = 'engine_behavior_v4' as const;
export const engineBehaviorVersion = 'engine_behavior_v5' as const;
export const engineInputVersion = 'engine_input_v1' as const;
export const engineCandidateSpaceVersion = 'engine_candidate_space_v1' as const;
export const engineAccountingVersion = 'engine_accounting_v1' as const;
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"type": "module",
"engines": {
"node": ">=24.15.0 <25"
"node": ">=24.14.1 <25"
},
"engineStrict": true,
"packageManager": "npm@11.12.1",
Expand Down
5 changes: 3 additions & 2 deletions scripts/check-vercel-parity.mts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ if (!isVercel) {
if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
guardrailFail('Unable to parse Node version', [process.version]);
}
const satisfiesNodeEngine = major === 24 && (minor > 15 || (minor === 15 && patch >= 0));
const satisfiesNodeEngine =
major === 24 && (minor > 14 || (minor === 14 && patch >= 1));
if (!satisfiesNodeEngine) {
guardrailFail('Node version must satisfy engines.node >=24.15.0 <25', [process.version]);
guardrailFail('Node version must satisfy engines.node >=24.14.1 <25', [process.version]);
}

const tmpRoot = process.env['CHERRY_TMP_ROOT'];
Expand Down
2 changes: 1 addition & 1 deletion scripts/guardrails/engine-freeze.policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
]
},
"engineVersions": {
"behavior": "engine_behavior_v4",
"behavior": "engine_behavior_v5",
"input": "engine_input_v1",
"candidateSpace": "engine_candidate_space_v1",
"accounting": "engine_accounting_v1"
Expand Down
69 changes: 69 additions & 0 deletions tests/engine-solver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,74 @@ async function testSolveDecisionSorts() {
assert.equal(bestCardDecision?.action.cardId, 'card-strong');
}

async function testMaxCandidatesCapsRankedOutputNotEvaluationOrder() {
const state = buildStubState({
cards: [
{
id: 'card-bad',
userId: 'user-1',
issuer: 'Issuer',
label: 'Bad Card',
network: 'VISA',
productSlug: null,
rewardRules: [
{
id: 'rule-bad',
cardId: 'card-bad',
categoryKey: 'DINING',
rateType: 'CASHBACK',
rateValue: 0.01,
confidence: 1,
source: 'STATIC_CONFIG',
},
],
isCredit: false,
isActive: true,
isVirtual: false,
},
{
id: 'card-good',
userId: 'user-1',
issuer: 'Issuer',
label: 'Good Card',
network: 'VISA',
productSlug: null,
rewardRules: [
{
id: 'rule-good',
cardId: 'card-good',
categoryKey: 'DINING',
rateType: 'CASHBACK',
rateValue: 0.03,
confidence: 1,
source: 'STATIC_CONFIG',
},
],
isCredit: false,
isActive: true,
isVirtual: false,
},
],
});
const ctx = buildStubContext({ amountCents: 1_000 });
const bad = { type: 'USE_CARD', cardId: 'card-bad' };
const good = { type: 'USE_CARD', cardId: 'card-good' };

async function topCardIdFor(candidateActionsOverride) {
const result = await solveDecision(state, ctx, {
candidateActionsOverride,
maxCandidates: 1,
});

assert.equal(result.decisions.length, 1);
assert.equal(result.trace.candidates.length, 1);
return result.decisions[0]?.action.cardId;
}

assert.equal(await topCardIdFor([bad, good]), 'card-good');
assert.equal(await topCardIdFor([good, bad]), 'card-good');
}

async function testDeterministicOrderingForEqualScores() {
const state = buildStubState({
debts: [
Expand Down Expand Up @@ -1253,6 +1321,7 @@ function testGetEngineCapabilitiesDefaultsToUnavailable() {

async function run() {
await testSolveDecisionSorts();
await testMaxCandidatesCapsRankedOutputNotEvaluationOrder();
await testDeterministicOrderingForEqualScores();
await testSolveDecisionValidation();
await testSafeSolveDecisionSuccess();
Expand Down
69 changes: 69 additions & 0 deletions tests/node/engine-solver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,74 @@ async function testSolveDecisionSorts() {
assert.equal(bestCardDecision?.action.cardId, 'card-strong');
}

async function testMaxCandidatesCapsRankedOutputNotEvaluationOrder() {
const state = buildStubState({
cards: [
{
id: 'card-bad',
userId: 'user-1',
issuer: 'Issuer',
label: 'Bad Card',
network: 'VISA',
productSlug: null,
rewardRules: [
{
id: 'rule-bad',
cardId: 'card-bad',
categoryKey: 'DINING',
rateType: 'CASHBACK',
rateValue: 0.01,
confidence: 1,
source: 'STATIC_CONFIG',
},
],
isCredit: false,
isActive: true,
isVirtual: false,
},
{
id: 'card-good',
userId: 'user-1',
issuer: 'Issuer',
label: 'Good Card',
network: 'VISA',
productSlug: null,
rewardRules: [
{
id: 'rule-good',
cardId: 'card-good',
categoryKey: 'DINING',
rateType: 'CASHBACK',
rateValue: 0.03,
confidence: 1,
source: 'STATIC_CONFIG',
},
],
isCredit: false,
isActive: true,
isVirtual: false,
},
],
});
const ctx = buildStubContext({ amountCents: 1_000 });
const bad = { type: 'USE_CARD', cardId: 'card-bad' };
const good = { type: 'USE_CARD', cardId: 'card-good' };

async function topCardIdFor(candidateActionsOverride) {
const result = await solveDecision(state, ctx, {
candidateActionsOverride,
maxCandidates: 1,
});

assert.equal(result.decisions.length, 1);
assert.equal(result.trace.candidates.length, 1);
return result.decisions[0]?.action.cardId;
}

assert.equal(await topCardIdFor([bad, good]), 'card-good');
assert.equal(await topCardIdFor([good, bad]), 'card-good');
}

async function testDeterministicOrderingForEqualScores() {
const state = buildStubState({
debts: [
Expand Down Expand Up @@ -1257,6 +1325,7 @@ function testGetEngineCapabilitiesDefaultsToUnavailable() {

async function run() {
await testSolveDecisionSorts();
await testMaxCandidatesCapsRankedOutputNotEvaluationOrder();
await testDeterministicOrderingForEqualScores();
await testSolveDecisionValidation();
await testSafeSolveDecisionSuccess();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"hashes": [
"603b9ebe8034a477e2969d7da70002d0bc8cf86b91f4c4c183fe58d26556c6fa"
],
"versions": {
"engineAccountingVersion": "engine_accounting_v1",
"engineBehaviorVersion": "engine_behavior_v5",
"engineCandidateSpaceVersion": "engine_candidate_space_v1",
"engineInputVersion": "engine_input_v1"
}
}
Loading