From a41d899e9ce84b5dd7199f55d40a18a78657aaa4 Mon Sep 17 00:00:00 2001 From: Mous <156965421+div0rce@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:01:02 -0400 Subject: [PATCH 1/2] feat(simulation): introduce unit-consistent objective semantics --- docs/config-snapshot.md | 3 + docs/engine-optimality/objective.md | 11 +- docs/engine-optimality/status.md | 5 + docs/guardrails.md | 7 + docs/simulation/objective-semantics.md | 74 ++++ lib/engine/objective.ts | 265 +++++++++--- lib/engine/objective/utility.ts | 58 +++ lib/engine/optimality/admissible.ts | 4 + lib/engine/solver.ts | 15 +- lib/engine/types.ts | 13 + lib/engine/version.ts | 2 +- package.json | 1 + scripts/check-objective-semantics.mts | 108 +++++ scripts/guardrails/registry.mts | 2 + tests/engine-accounting-proof.test.ts | 4 + tests/engine-accounting-time-order.test.ts | 4 + tests/engine-solver.test.js | 63 +++ .../admissibility-equivalence.spec.ts | 4 + tests/node/engine-accounting-proof.test.ts | 4 + .../node/engine-accounting-time-order.test.ts | 4 + tests/node/engine-solver.test.js | 63 +++ .../admissibility-equivalence.spec.ts | 4 + tests/node/objective-utility.test.ts | 45 ++ ...didate_space_v1__engine_accounting_v1.json | 11 + ...9485cfddd761980910879e8f4b1b916c4c9b8.json | 399 ++++++++++++++++++ 25 files changed, 1122 insertions(+), 51 deletions(-) create mode 100644 docs/simulation/objective-semantics.md create mode 100644 lib/engine/objective/utility.ts create mode 100644 scripts/check-objective-semantics.mts create mode 100644 tests/node/objective-utility.test.ts create mode 100644 tests/replay/index/engine@engine_behavior_v6__engine_input_v1__engine_candidate_space_v1__engine_accounting_v1.json create mode 100644 tests/replay/objects/8f/0c/8f0cf4f6b3683f107427e15adb49485cfddd761980910879e8f4b1b916c4c9b8.json diff --git a/docs/config-snapshot.md b/docs/config-snapshot.md index d5f2035e..53f31289 100644 --- a/docs/config-snapshot.md +++ b/docs/config-snapshot.md @@ -9699,6 +9699,7 @@ export default nextConfig; "check:engine-date": "npm run ts:esm -- scripts/guardrails/run.mts check:engine-date", "check:engine-optimality": "npm run ts:esm -- scripts/guardrails/run.mts check:engine-optimality", "check:engine-optimality-version": "npm run ts:esm -- scripts/guardrails/run.mts check:engine-optimality-version", + "check:objective-semantics": "npm run ts:esm -- scripts/guardrails/run.mts check:objective-semantics", "check:catch-unknown": "npm run ts:esm -- scripts/guardrails/run.mts check:catch-unknown", "check:guardrails-core": "npm run ts:esm -- scripts/guardrails/run.mts check:guardrails-core", "check:repo-guardrails": "npm run ts:esm -- scripts/guardrails/run.mts check:repo-guardrails", @@ -10774,6 +10775,7 @@ const CONFIG_SNAPSHOT_PATH = `${CHECK_PATH_BASE}config-snapshot.mts` as const; const ENGINE_OPTIMALITY_PATH = `${CHECK_PATH_BASE}engine-optimality.mts` as const; const ENGINE_OPTIMALITY_VERSION_PATH = `${CHECK_PATH_BASE}engine-optimality-version.mts` as const; const ENGINE_INPUT_BOUNDARY_PATH = `${CHECK_PATH_BASE}engine-input-boundary.mts` as const; +const OBJECTIVE_SEMANTICS_PATH = `${CHECK_PATH_BASE}objective-semantics.mts` as const; const REPLAY_STAGING_EMPTY_PATH = `${CHECK_PATH_BASE}replay-staging-empty.mts` as const; const REPLAY_OBJECT_STORE_PATH = `${CHECK_PATH_BASE}replay-object-store.mts` as const; const ENV_CONTRACT_PATH = `${CHECK_PATH_BASE}env-contract.mts` as const; @@ -10882,6 +10884,7 @@ export const GUARDRAILS = Object.freeze({ 'check:engine-optimality': ENGINE_OPTIMALITY_PATH, 'check:engine-optimality-version': ENGINE_OPTIMALITY_VERSION_PATH, 'check:engine-input-boundary': ENGINE_INPUT_BOUNDARY_PATH, + 'check:objective-semantics': OBJECTIVE_SEMANTICS_PATH, 'check:replay-staging-empty': REPLAY_STAGING_EMPTY_PATH, 'check:replay-object-store': REPLAY_OBJECT_STORE_PATH, 'check:env-contract': ENV_CONTRACT_PATH, diff --git a/docs/engine-optimality/objective.md b/docs/engine-optimality/objective.md index 60322cbb..c3c17fd9 100644 --- a/docs/engine-optimality/objective.md +++ b/docs/engine-optimality/objective.md @@ -24,7 +24,15 @@ Last updated: 2026-01-18 The vector is total for all finite scores and uses only stable strings. -The underlying scalar score that feeds this vector is bounded heuristic scoring, not a claim of economic optimality. Live dimensions are limited to `rewards`, `runway`, and `debtRelief`, and raw issuer points are not treated as monetary value unless runtime truth provides an explicit valuation. +The underlying scalar score that feeds this vector is `objectiveUtilityCents` +with serialized unit label `utility_usd_cents`. It is bounded heuristic scoring, +not a claim of economic optimality. Live dimensions are converted into +documented utility-adjusted USD cents before ranking. Component `utilityCents` +values are final objective contributions after configured preference weights and +should not be interpreted as literal market cash value. + +Raw issuer points do not enter objective math directly. They are converted +through `REWARD_POINT_VALUE_CENTS`. ### Ordering and tie-break @@ -58,3 +66,4 @@ and injective. - `docs/engine-optimality/candidate-space.md` - `docs/engine-optimality/status.md` - `docs/engine-optimality/trace.md` +- `docs/simulation/objective-semantics.md` diff --git a/docs/engine-optimality/status.md b/docs/engine-optimality/status.md index 8c0e3b15..6bf96a13 100644 --- a/docs/engine-optimality/status.md +++ b/docs/engine-optimality/status.md @@ -7,6 +7,10 @@ Last updated: 2026-04-28 Cherry evaluates a bounded generated candidate set with deterministic heuristic ranking. It does not prove global optimality over all possible financial actions. +Live candidate ranking uses `objectiveUtilityCents` with serialized unit label +`utility_usd_cents`. This is a bounded heuristic objective expressed in one +canonical unit, not a true global utility function. + ### Proven (bounded) - Bounded exact optimality is proven for `(objective_v1, candidates_v1)` under @@ -38,3 +42,4 @@ Cherry evaluates a bounded generated candidate set with deterministic heuristic - `docs/engine-optimality/objective.md` - `docs/engine-optimality/candidate-space.md` - `docs/engine-optimality/trace.md` +- `docs/simulation/objective-semantics.md` diff --git a/docs/guardrails.md b/docs/guardrails.md index 15f461d4..ff1f9687 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -28,6 +28,7 @@ Last updated: 2026-01-31 - Accounting invariants run as deterministic guardrails over `lib/accounting` and its property tests. - Engine optimality guardrail runs bounded oracle tests via `check:engine-optimality`. - Engine optimality versions are frozen by `check:engine-optimality-version`. +- Objective semantics are enforced for live objective paths via `check:objective-semantics`. - Guardrail runner supports `--aggregate` shadow execution; it accepts guardrail names only (no per-guardrail args) and reports in registry order by default (`--sort=name` for alphabetical). - Workflow presence, quoted expressions, runner-context, and delete-safety are enforced (`check:workflow-files-present`, `check:workflow-expressions-quoted`, `check:workflow-runner-context`, `check:no-workflow-force-delete`). @@ -351,6 +352,12 @@ Guardrail checks: `check:branded-literal` and `tests/node/guardrails/branded-typ - Boundary files (`lib/engine/input/**`, `check:engine-freeze`) may not use array element access (`arr[i]`). - Enforcement: `check:engine-input-boundary`. +### Guardrail 57 — Objective Semantics + +- Live objective math under `lib/engine/objective.ts`, `lib/engine/objective/**`, and `lib/simulation/**` must not use raw issuer points or legacy `POINTS_PER_DOLLAR` values as objective value. +- Reward points must convert through `rewardPointsToUtilityCents`. +- Enforcement: `check:objective-semantics`. + ### Guardrail 58 — Replay Staging Hygiene - Replay staging artifacts must never be committed under `tests/replay/_staging/**`. diff --git a/docs/simulation/objective-semantics.md b/docs/simulation/objective-semantics.md new file mode 100644 index 00000000..22462ed4 --- /dev/null +++ b/docs/simulation/objective-semantics.md @@ -0,0 +1,74 @@ +Status: Active +Last updated: 2026-04-28 + +# Objective Semantics + +## Current behavior + +### Canonical unit + +Cherry scores live candidates as `objectiveUtilityCents`. + +Canonical unit: utility-adjusted USD cents. +Serialized unit label: `utility_usd_cents`. + +Legacy `score` is a compatibility alias for `objectiveUtilityCents`; both values +must come from the same computation. + +### Reward value + +Cashback reward value enters the objective at face value in cents. + +Issuer reward points are converted through `REWARD_POINT_VALUE_CENTS`. The +current live mapping is 1 point = 1 utility-adjusted cent. Raw points do not +enter objective math directly. + +### Weighted component semantics + +Component `utilityCents` values are final objective contributions after +configured preference weights. They should not be interpreted as literal market +cash value. + +### Cash benefit + +Cash-denominated benefits enter the objective through cent-denominated +conversion before profile weighting. + +### Debt relief + +Debt relief enters as a bounded utility-adjusted contribution. Utilization +relief is converted with +`UTILIZATION_RELIEF_UTILITY_CENTS_PER_BASIS_POINT`; balance relief is converted +from debt cents with an explicit utility mapping. These constants make the +heuristic objective interpretable without claiming that the result is literal +market cash value. + +The current debt-relief constants are intentionally conservative. Debt relief is +allowed to influence ranking, especially under debt-focused profile weights, but +it is not treated as face-value cash benefit. + +### Liquidity pressure + +Liquidity and affordability pressure are bounded non-utility heuristic +contributions. They discourage fragile near-term liquidity outcomes and are +reported as bounded heuristic components in `scoreComponents`. + +### Final interpretation + +Cherry uses a bounded heuristic objective expressed in one canonical unit. It +does not define a true global utility function. + +Cherry does not claim long-horizon global optimality. It ranks the currently generated candidate set under a documented, unit-consistent objective. + +## Future/Target behavior + +- Any new score dimension must either convert into `objectiveUtilityCents` or be + explicitly documented as a bounded non-utility heuristic contribution. +- Any change to live objective semantics must update this document, tests, and + engine behavior versioning. + +## Related docs + +- `docs/engine-optimality/objective.md` +- `docs/engine-optimality/status.md` +- `docs/engine-optimality/candidate-space.md` diff --git a/lib/engine/objective.ts b/lib/engine/objective.ts index 32765644..e41a9cbf 100644 --- a/lib/engine/objective.ts +++ b/lib/engine/objective.ts @@ -19,6 +19,35 @@ import { } from './types.js'; import { getRewardSemanticsForCardSpend } from './reward-semantics.js'; import { DEFAULT_ENGINE_RUNTIME, type EngineRuntime } from './runtime.js'; +import { + OBJECTIVE_SCORE_UNIT, + centsToUtilityCents, + rewardPointsToUtilityCents, + sumObjectiveUtility, + utilityCents, + type ObjectiveComponent, + type UtilityCents, +} from './objective/utility.js'; + +export { + OBJECTIVE_SCORE_UNIT, + POINTS_PER_DOLLAR, + REWARD_POINT_VALUE_CENTS, + centsToUtilityCents, + dollarsToUtilityCents, + pointsToUtilityCents, + rewardPointsToUtilityCents, + sumObjectiveUtility, + utilityCents, + type ObjectiveComponent, + type ObjectiveComponentKind, + type ObjectiveScoreUnit, + type UtilityCents, +} from './objective/utility.js'; + +export const UTILIZATION_RELIEF_UTILITY_CENTS_PER_BASIS_POINT = 0.0001; +export const DEBT_BALANCE_RELIEF_UTILITY_CENTS_PER_DEBT_CENT = 0.0001; +export const PAYDOWN_ACTION_BONUS_UTILITY_CENTS = 1; function hasNonEmptyString(value?: string | null): value is string { return value !== undefined && value !== null && value !== ''; @@ -156,7 +185,45 @@ export function getObjectiveWeightsForState( return getObjectiveWeightsForPreferences(state.preferences, runtime); } -function scoreComponents( +type ObjectiveBuildResult = { + objectiveUtilityCents: UtilityCents; + scoreComponents: readonly ObjectiveComponent[]; + components: ObjectiveComponentScores; + weights: ObjectiveWeights; +}; + +function weightedUtilityCents(value: UtilityCents, weight: number): UtilityCents { + return utilityCents(Number(value) * weight); +} + +function pushWeightedComponent( + components: ObjectiveComponent[], + params: { + key: string; + kind: ObjectiveComponent['kind']; + rawUtilityCents: UtilityCents; + weight: number; + interpretation: string; + boundedHeuristic?: boolean; + } +): UtilityCents { + const utilityCentsValue = weightedUtilityCents(params.rawUtilityCents, params.weight); + if (utilityCentsValue === 0) return utilityCentsValue; + components.push({ + key: params.key, + kind: params.kind, + utilityCents: utilityCentsValue, + interpretation: params.interpretation, + ...(params.boundedHeuristic === true ? { boundedHeuristic: true } : {}), + }); + return utilityCentsValue; +} + +function basisPoints(delta: number): number { + return delta * 10_000; +} + +function buildObjective( state: EngineState, ctx: EngineContext, action: EngineAction, @@ -164,10 +231,18 @@ function scoreComponents( buckets: BucketProjection[]; debt: DebtProjection[]; cash: CashProjection; - } -): ObjectiveComponentScores { - // 1) Rewards estimate. - let rewards = 0; + }, + weights: ObjectiveWeights +): ObjectiveBuildResult { + const normalizedWeights = normalizeObjectiveWeights(weights); + const scoreComponents: ObjectiveComponent[] = []; + const components: ObjectiveComponentScores = { + rewards: 0, + runway: 0, + debtRelief: 0, + }; + + // 1) Reward estimate. Points are explicitly valued before entering the objective. if ( (action.type === 'USE_CARD' || action.type === 'USE_CARD_WITH_PAYDOWN') && hasNonEmptyString(action.cardId) && @@ -180,12 +255,36 @@ function scoreComponents( amountCents: ctx.amountCents, merchantCategoryKey: ctx.merchantCategoryKey, }); - rewards = centsOrZero(rewardSemantics?.rewardValueCents); + if (rewardSemantics?.rewardUnit === 'cashback_cents') { + const rewardValueCents = centsToUtilityCents(centsOrZero(rewardSemantics.rewardValueCents)); + components.rewards = Number(rewardValueCents); + pushWeightedComponent(scoreComponents, { + key: 'reward_value', + kind: 'reward_value', + rawUtilityCents: rewardValueCents, + weight: normalizedWeights.rewards, + interpretation: + 'Cashback reward value converted at face value into objectiveUtilityCents and multiplied by the rewards preference weight.', + }); + } else if (rewardSemantics?.rewardUnit === 'issuer_points') { + const rewardPoints = + rewardSemantics.rewardPoints == null ? 0 : rewardSemantics.rewardPoints; + const rewardValueCents = rewardPointsToUtilityCents(rewardPoints); + components.rewards = Number(rewardValueCents); + pushWeightedComponent(scoreComponents, { + key: 'reward_point_value', + kind: 'reward_value', + rawUtilityCents: rewardValueCents, + weight: normalizedWeights.rewards, + interpretation: + 'Issuer points converted through REWARD_POINT_VALUE_CENTS into objectiveUtilityCents and multiplied by the rewards preference weight.', + }); + } } } - // 2) Runway: prefer leaving room in essential buckets. - let runway = 0; + // 2) Runway: bounded heuristic pressure for essential liquidity margin. + let essentialMarginUtilityCents = 0; const capabilities = getEngineCapabilities(state); if (capabilities.essentiality.available === true) { for (const proj of projections.buckets) { @@ -196,16 +295,27 @@ function scoreComponents( isBucketEssential(bucket) && bucket.limitCents != null ) { - runway += proj.projectedRemainingCents; + essentialMarginUtilityCents += proj.projectedRemainingCents; } } } + if (essentialMarginUtilityCents !== 0) { + components.runway += essentialMarginUtilityCents; + pushWeightedComponent(scoreComponents, { + key: 'essential_margin_runway', + kind: 'liquidity_pressure', + rawUtilityCents: utilityCents(essentialMarginUtilityCents), + weight: normalizedWeights.runway, + interpretation: + 'Bounded non-utility heuristic for projected essential bucket margin, multiplied by the runway preference weight.', + boundedHeuristic: true, + }); + } - // 3) Debt relief: reward lower utilization or balances, plus explicit paydowns. - // This remains a bounded heuristic, but the subterms are now explicit. - let utilizationImprovementScore = 0; - let balanceReductionScore = 0; - let paydownActionBonus = 0; + // 3) Debt relief: bounded utility-adjusted contribution with explicit constants. + let utilizationReliefUtilityCents = 0; + let balanceReliefUtilityCents = 0; + let paydownActionUtilityCents = 0; const debts = getDebtAccounts(state.debts); if (capabilities.debt.available === true) { for (const proj of projections.debt) { @@ -223,10 +333,13 @@ function scoreComponents( proj.projectedUtilization != null && currentUtil != null ) { - utilizationImprovementScore += currentUtil - proj.projectedUtilization; + utilizationReliefUtilityCents += + basisPoints(currentUtil - proj.projectedUtilization) * + UTILIZATION_RELIEF_UTILITY_CENTS_PER_BASIS_POINT; } else { const balanceDelta = debt.balanceCents - proj.projectedBalanceCents; - balanceReductionScore += balanceDelta / 100_00; + balanceReliefUtilityCents += + balanceDelta * DEBT_BALANCE_RELIEF_UTILITY_CENTS_PER_DEBT_CENT; } } } @@ -238,36 +351,60 @@ function scoreComponents( !Number.isNaN(action.paydownAmountCents) && action.paydownAmountCents !== 0 ) { - paydownActionBonus += 1; + paydownActionUtilityCents += PAYDOWN_ACTION_BONUS_UTILITY_CENTS; } const debtRelief = - utilizationImprovementScore + balanceReductionScore + paydownActionBonus; + utilizationReliefUtilityCents + balanceReliefUtilityCents + paydownActionUtilityCents; + if (debtRelief !== 0) { + components.debtRelief = Number(utilityCents(debtRelief)); + pushWeightedComponent(scoreComponents, { + key: 'debt_relief', + kind: 'debt_relief', + rawUtilityCents: utilityCents(debtRelief), + weight: normalizedWeights.debtRelief, + interpretation: + 'Debt relief converted through explicit utilization and balance-relief utility constants, then multiplied by the debt-relief preference weight.', + boundedHeuristic: true, + }); + } if (action.type === 'DELAY_PURCHASE' && hasNonZeroNumber(ctx.amountCents)) { - runway -= 0.01 * ctx.amountCents; + const delayPenalty = -0.01 * ctx.amountCents; + components.runway += delayPenalty; + pushWeightedComponent(scoreComponents, { + key: 'delay_purchase_liquidity_pressure', + kind: 'liquidity_pressure', + rawUtilityCents: utilityCents(delayPenalty), + weight: normalizedWeights.runway, + interpretation: + 'Bounded non-utility penalty used to discourage fragile near-term liquidity outcomes from delaying the purchase.', + boundedHeuristic: true, + }); } if (action.type === 'REJECT_PURCHASE' && hasNonZeroNumber(ctx.amountCents)) { - runway -= 0.05 * ctx.amountCents; + const rejectPenalty = -0.05 * ctx.amountCents; + components.runway += rejectPenalty; + pushWeightedComponent(scoreComponents, { + key: 'reject_purchase_liquidity_pressure', + kind: 'liquidity_pressure', + rawUtilityCents: utilityCents(rejectPenalty), + weight: normalizedWeights.runway, + interpretation: + 'Bounded non-utility penalty used to represent the friction of rejecting the purchase while protecting constraints.', + boundedHeuristic: true, + }); } + const objectiveUtilityCents = sumObjectiveUtility(scoreComponents); return { - rewards, - runway, - debtRelief, + objectiveUtilityCents, + scoreComponents, + components, + weights: normalizedWeights, }; } -function combineScores(components: ObjectiveComponentScores, weights: ObjectiveWeights): number { - const w = normalizeObjectiveWeights(weights); - - return ( - components.rewards * w.rewards + - components.runway * w.runway + - components.debtRelief * w.debtRelief - ); -} - // Main scoring function that returns a scalar + human-readable reasons. export function scoreDecision( state: EngineState, @@ -279,11 +416,17 @@ export function scoreDecision( cash: CashProjection; }, weights?: ObjectiveWeights -): { score: number; reasons: string[]; components: ObjectiveComponentScores; weights: ObjectiveWeights } { - const components = scoreComponents(state, ctx, action, projections); +): { + score: number; + objectiveUtilityCents: UtilityCents; + scoreUnit: typeof OBJECTIVE_SCORE_UNIT; + scoreComponents: readonly ObjectiveComponent[]; + reasons: string[]; + components: ObjectiveComponentScores; + weights: ObjectiveWeights; +} { const resolvedWeights = weights == null ? getObjectiveWeightsForState(state) : weights; - const normalizedWeights = normalizeObjectiveWeights(resolvedWeights); - const score = combineScores(components, normalizedWeights); + const objective = buildObjective(state, ctx, action, projections, resolvedWeights); const reasons: string[] = []; const rewardSemantics = @@ -301,18 +444,25 @@ export function scoreDecision( if (rewardValueCents > 0) { reasons.push( - `Estimated cashback value: $${formatCentsAsDollars(rewardValueCents)}.` + `Estimated cashback objective value: $${formatCentsAsDollars(rewardValueCents)} before preference weighting.` ); } else if (rewardPoints > 0) { - reasons.push(`Estimated issuer points: ${rewardPoints}.`); + const pointUtilityCents = rewardPointsToUtilityCents(rewardPoints); + reasons.push( + `Estimated issuer point objective value: $${formatCentsAsDollars(pointUtilityCents)} using REWARD_POINT_VALUE_CENTS.` + ); } - if (components.runway !== 0) { - reasons.push('Heuristic essential-margin runway adjusted.'); + if (objective.components.runway !== 0) { + reasons.push( + 'Applied bounded liquidity-pressure heuristic in objectiveUtilityCents.' + ); } - if (components.debtRelief !== 0) { - reasons.push('Debt-pressure relief adjusted heuristically.'); + if (objective.components.debtRelief !== 0) { + reasons.push( + 'Applied bounded debt-relief contribution through documented utility constants.' + ); } if ( @@ -339,7 +489,15 @@ export function scoreDecision( reasons.push('Recommends skipping this purchase to protect constraints.'); } - return { score, reasons, components, weights: normalizedWeights }; + return { + score: objective.objectiveUtilityCents, + objectiveUtilityCents: objective.objectiveUtilityCents, + scoreUnit: OBJECTIVE_SCORE_UNIT, + scoreComponents: objective.scoreComponents, + reasons, + components: objective.components, + weights: objective.weights, + }; } export function scoreAction( @@ -352,8 +510,19 @@ export function scoreAction( cash: CashProjection; }, weights: ObjectiveWeights -): { score: number; components: ObjectiveComponentScores } { - const components = scoreComponents(state, ctx, action, projections); - const score = combineScores(components, weights); - return { score, components }; +): { + score: number; + objectiveUtilityCents: UtilityCents; + scoreUnit: typeof OBJECTIVE_SCORE_UNIT; + scoreComponents: readonly ObjectiveComponent[]; + components: ObjectiveComponentScores; +} { + const objective = buildObjective(state, ctx, action, projections, weights); + return { + score: objective.objectiveUtilityCents, + objectiveUtilityCents: objective.objectiveUtilityCents, + scoreUnit: OBJECTIVE_SCORE_UNIT, + scoreComponents: objective.scoreComponents, + components: objective.components, + }; } diff --git a/lib/engine/objective/utility.ts b/lib/engine/objective/utility.ts new file mode 100644 index 00000000..32118866 --- /dev/null +++ b/lib/engine/objective/utility.ts @@ -0,0 +1,58 @@ +export type UtilityCents = number & { readonly __brand: 'UtilityCents' }; + +export const OBJECTIVE_SCORE_UNIT = 'utility_usd_cents' as const; +export type ObjectiveScoreUnit = typeof OBJECTIVE_SCORE_UNIT; + +const CENTS_PER_DOLLAR = 100; + +export const REWARD_POINT_VALUE_CENTS = 1; + +/** @deprecated Use REWARD_POINT_VALUE_CENTS and rewardPointsToUtilityCents. */ +export const POINTS_PER_DOLLAR = 100; + +export type ObjectiveComponentKind = + | 'cash_benefit' + | 'reward_value' + | 'debt_relief' + | 'liquidity_pressure' + | 'constraint_penalty' + | 'bounded_heuristic'; + +export type ObjectiveComponent = { + key: string; + kind: ObjectiveComponentKind; + utilityCents: UtilityCents; + interpretation: string; + boundedHeuristic?: boolean; +}; + +export function utilityCents(value: number): UtilityCents { + if (!Number.isFinite(value)) { + throw new Error('Utility cents must be finite'); + } + return value as UtilityCents; +} + +export function rewardPointsToUtilityCents(points: number): UtilityCents { + return utilityCents(points * REWARD_POINT_VALUE_CENTS); +} + +export function pointsToUtilityCents(points: number): UtilityCents { + return rewardPointsToUtilityCents(points); +} + +export function dollarsToUtilityCents(dollars: number): UtilityCents { + return utilityCents(Math.round(dollars * CENTS_PER_DOLLAR)); +} + +export function centsToUtilityCents(cents: number): UtilityCents { + return utilityCents(cents); +} + +export function sumObjectiveUtility( + components: readonly ObjectiveComponent[] +): UtilityCents { + return utilityCents( + components.reduce((sum, component) => sum + component.utilityCents, 0) + ); +} diff --git a/lib/engine/optimality/admissible.ts b/lib/engine/optimality/admissible.ts index 5088e546..c805ed79 100644 --- a/lib/engine/optimality/admissible.ts +++ b/lib/engine/optimality/admissible.ts @@ -6,6 +6,7 @@ import { } from '../guardrails.js'; import { simulateAction } from '../simulate.js'; import type { EngineContext, EngineDecision, EngineState } from '../types.js'; +import { OBJECTIVE_SCORE_UNIT } from '../objective/utility.js'; import type { Candidate } from './candidates.js'; import { candidateKey, normalizeCandidate, normalizeCandidateToAction } from './normalize.js'; @@ -27,6 +28,9 @@ export function isAdmissible( const decision: EngineDecision = { actionId: candidateKey(normalized), action, + objectiveUtilityCents: 0, + scoreUnit: OBJECTIVE_SCORE_UNIT, + scoreComponents: [], score: 0, reasons: [], projections, diff --git a/lib/engine/solver.ts b/lib/engine/solver.ts index 518a3995..115ae75e 100644 --- a/lib/engine/solver.ts +++ b/lib/engine/solver.ts @@ -155,7 +155,14 @@ export async function solveDecision( for (const action of surfaceFilteredCandidates) { const projections = simulateAction(state, ctx, action, { scheduledPaydownEvaluation }); - const { score, reasons, components } = scoreDecision(state, ctx, action, projections, weights); + const { + score, + objectiveUtilityCents, + scoreUnit, + scoreComponents, + reasons, + components, + } = scoreDecision(state, ctx, action, projections, weights); const constraintTags = evaluateConstraintsForDecision(state, ctx, action, projections); for (const constraint of hardConstraints) { @@ -165,6 +172,9 @@ export async function solveDecision( decisions.push({ actionId: buildActionId(action), action, + objectiveUtilityCents, + scoreUnit, + scoreComponents, score, reasons, projections, @@ -198,6 +208,9 @@ export async function solveDecision( }, candidates: surfaced.map((d) => ({ action: d.action, + objectiveUtilityCents: d.objectiveUtilityCents, + scoreUnit: d.scoreUnit, + scoreComponents: d.scoreComponents, score: d.score, constraintsBreached: d.constraintsBreached, ...(d.components ? { components: d.components } : {}), diff --git a/lib/engine/types.ts b/lib/engine/types.ts index cc0a80a2..a509370a 100644 --- a/lib/engine/types.ts +++ b/lib/engine/types.ts @@ -2,6 +2,11 @@ // These types are intentionally decoupled from Prisma and React so the engine // can run in isolation and be reused across surfaces. +import type { + ObjectiveComponent, + ObjectiveScoreUnit, +} from './objective/utility.js'; + export type EngineSurface = | 'web' | 'extension' @@ -274,6 +279,10 @@ export type CashProjection = { export type EngineDecision = { actionId: string; action: EngineAction; + objectiveUtilityCents: number; + scoreUnit: ObjectiveScoreUnit; + scoreComponents: readonly ObjectiveComponent[]; + // Compatibility alias for objectiveUtilityCents. score: number; reasons: string[]; projections: { @@ -335,6 +344,10 @@ export type EngineDecisionTrace = { }; candidates: { action: EngineAction; + objectiveUtilityCents: number; + scoreUnit: ObjectiveScoreUnit; + scoreComponents: readonly ObjectiveComponent[]; + // Compatibility alias for objectiveUtilityCents. score: number; constraintsBreached: string[]; components?: ObjectiveComponentScores; diff --git a/lib/engine/version.ts b/lib/engine/version.ts index b4dba5f8..f3d2aa5d 100644 --- a/lib/engine/version.ts +++ b/lib/engine/version.ts @@ -1,4 +1,4 @@ -export const engineBehaviorVersion = 'engine_behavior_v5' as const; +export const engineBehaviorVersion = 'engine_behavior_v6' 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; diff --git a/package.json b/package.json index 534825a7..8abd66bb 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "check:engine-date": "npm run ts:esm -- scripts/guardrails/run.mts check:engine-date", "check:engine-optimality": "npm run ts:esm -- scripts/guardrails/run.mts check:engine-optimality", "check:engine-optimality-version": "npm run ts:esm -- scripts/guardrails/run.mts check:engine-optimality-version", + "check:objective-semantics": "npm run ts:esm -- scripts/guardrails/run.mts check:objective-semantics", "check:catch-unknown": "npm run ts:esm -- scripts/guardrails/run.mts check:catch-unknown", "check:guardrails-core": "npm run ts:esm -- scripts/guardrails/run.mts check:guardrails-core", "check:repo-guardrails": "npm run ts:esm -- scripts/guardrails/run.mts check:repo-guardrails", diff --git a/scripts/check-objective-semantics.mts b/scripts/check-objective-semantics.mts new file mode 100644 index 00000000..2e2927c6 --- /dev/null +++ b/scripts/check-objective-semantics.mts @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fail } from './guardrails/lib/fail.mjs'; + +const ROOT = process.cwd(); +const PREFIX = 'check:objective-semantics'; +const FIX = + 'Convert reward points through rewardPointsToUtilityCents and keep objective math in objectiveUtilityCents.'; + +const SCAN_ROOTS = [ + path.join(ROOT, 'lib', 'engine', 'objective.ts'), + path.join(ROOT, 'lib', 'engine', 'objective'), + path.join(ROOT, 'lib', 'simulation'), +]; + +type Finding = { + filePath: string; + lineNumber: number; + message: string; +}; + +function listFiles(entryPath: string): string[] { + if (!fs.existsSync(entryPath)) return []; + const stat = fs.statSync(entryPath); + if (stat.isFile()) { + return /\.(?:ts|tsx|js|mjs)$/.test(entryPath) ? [entryPath] : []; + } + const results: string[] = []; + for (const entry of fs.readdirSync(entryPath, { withFileTypes: true })) { + const child = path.join(entryPath, entry.name); + if (entry.isDirectory()) { + results.push(...listFiles(child)); + } else if (entry.isFile() && /\.(?:ts|tsx|js|mjs)$/.test(child)) { + results.push(child); + } + } + return results.sort(); +} + +function isAllowedDeprecatedPointConstant(filePath: string, line: string): boolean { + if ( + filePath.endsWith(path.join('lib', 'engine', 'objective.ts')) && + line.trim() === 'POINTS_PER_DOLLAR,' + ) { + return true; + } + return ( + filePath.endsWith(path.join('lib', 'engine', 'objective', 'utility.ts')) && + (line.includes('POINTS_PER_DOLLAR') || line.includes('@deprecated')) + ); +} + +function scanFile(filePath: string): Finding[] { + const relPath = path.relative(ROOT, filePath); + const text = fs.readFileSync(filePath, 'utf8'); + const findings: Finding[] = []; + const lines = text.split(/\r?\n/); + lines.forEach((line, index) => { + const lineNumber = index + 1; + if ( + line.includes('POINTS_PER_DOLLAR') && + !isAllowedDeprecatedPointConstant(filePath, line) + ) { + findings.push({ + filePath: relPath, + lineNumber, + message: 'POINTS_PER_DOLLAR is forbidden in live objective math', + }); + } + + if (/rewardValueCents\s*\?\?\s*rewardPoints/.test(line)) { + findings.push({ + filePath: relPath, + lineNumber, + message: 'raw reward points must not be a fallback objective value', + }); + } + + if ( + (/\brewardPoints\b\s*[+\-*/]/.test(line) || + /[+\-*/]\s*\brewardPoints\b/.test(line)) && + !line.includes('rewardPointsToUtilityCents') + ) { + findings.push({ + filePath: relPath, + lineNumber, + message: 'reward points must be converted before objective arithmetic', + }); + } + }); + return findings; +} + +const files = SCAN_ROOTS.flatMap(listFiles); +const findings = files.flatMap(scanFile); + +if (findings.length > 0) { + fail(PREFIX, 'Objective semantics guardrail failed', { + details: findings.map( + (finding) => `${finding.filePath}:${finding.lineNumber}:1: ${finding.message}` + ), + fix: FIX, + }); +} + +process.stdout.write('check:objective-semantics: ok\n'); diff --git a/scripts/guardrails/registry.mts b/scripts/guardrails/registry.mts index 5b77a8a2..3682fc0c 100644 --- a/scripts/guardrails/registry.mts +++ b/scripts/guardrails/registry.mts @@ -42,6 +42,7 @@ const CONFIG_SNAPSHOT_PATH = `${CHECK_PATH_BASE}config-snapshot.mts` as const; const ENGINE_OPTIMALITY_PATH = `${CHECK_PATH_BASE}engine-optimality.mts` as const; const ENGINE_OPTIMALITY_VERSION_PATH = `${CHECK_PATH_BASE}engine-optimality-version.mts` as const; const ENGINE_INPUT_BOUNDARY_PATH = `${CHECK_PATH_BASE}engine-input-boundary.mts` as const; +const OBJECTIVE_SEMANTICS_PATH = `${CHECK_PATH_BASE}objective-semantics.mts` as const; const REPLAY_STAGING_EMPTY_PATH = `${CHECK_PATH_BASE}replay-staging-empty.mts` as const; const REPLAY_OBJECT_STORE_PATH = `${CHECK_PATH_BASE}replay-object-store.mts` as const; const ENV_CONTRACT_PATH = `${CHECK_PATH_BASE}env-contract.mts` as const; @@ -150,6 +151,7 @@ export const GUARDRAILS = Object.freeze({ 'check:engine-optimality': ENGINE_OPTIMALITY_PATH, 'check:engine-optimality-version': ENGINE_OPTIMALITY_VERSION_PATH, 'check:engine-input-boundary': ENGINE_INPUT_BOUNDARY_PATH, + 'check:objective-semantics': OBJECTIVE_SEMANTICS_PATH, 'check:replay-staging-empty': REPLAY_STAGING_EMPTY_PATH, 'check:replay-object-store': REPLAY_OBJECT_STORE_PATH, 'check:env-contract': ENV_CONTRACT_PATH, diff --git a/tests/engine-accounting-proof.test.ts b/tests/engine-accounting-proof.test.ts index b17af6d1..b4599a41 100644 --- a/tests/engine-accounting-proof.test.ts +++ b/tests/engine-accounting-proof.test.ts @@ -19,6 +19,7 @@ import { createLoadedEngineCapabilities, type EngineState, } from '../lib/engine/types.js'; +import { OBJECTIVE_SCORE_UNIT } from '../lib/engine/objective/utility.js'; import { buildAccountingSnapshot, filterAccountingSafeDecisions, @@ -152,6 +153,9 @@ async function testFiltersUnsafeDecisions(): Promise { const safeDecision: EngineDecisionWithAccounting = { actionId: 'safe', action: { type: 'REJECT_PURCHASE' }, + objectiveUtilityCents: 1, + scoreUnit: OBJECTIVE_SCORE_UNIT, + scoreComponents: [], score: 1, reasons: [], projections: { buckets: [], debt: [], cash: { projectedLiquidCents: null, projectedOverdraftRisk: null } }, diff --git a/tests/engine-accounting-time-order.test.ts b/tests/engine-accounting-time-order.test.ts index 322a33ea..aa85d685 100644 --- a/tests/engine-accounting-time-order.test.ts +++ b/tests/engine-accounting-time-order.test.ts @@ -21,6 +21,7 @@ import { type EngineDecision, type EngineState, } from '../lib/engine/types.js'; +import { OBJECTIVE_SCORE_UNIT } from '../lib/engine/objective/utility.js'; import { attachAccountingProof, buildAccountingSnapshot, @@ -50,6 +51,9 @@ function buildDecision(actionType: EngineActionType): EngineDecision { return { actionId: `decision-${actionType}`, action: { type: actionType }, + objectiveUtilityCents: 1, + scoreUnit: OBJECTIVE_SCORE_UNIT, + scoreComponents: [], score: 1, reasons: [], projections: { buckets: [], debt: [], cash: { projectedLiquidCents: null, projectedOverdraftRisk: null } }, diff --git a/tests/engine-solver.test.js b/tests/engine-solver.test.js index 13a622e9..9c0edb09 100644 --- a/tests/engine-solver.test.js +++ b/tests/engine-solver.test.js @@ -12,6 +12,7 @@ const { evaluateConstraintsForDecision, enforceHardConstraints, EngineError, + OBJECTIVE_SCORE_UNIT, createLoadedEngineCapabilities, createUnavailableEngineCapabilities, getEngineRuntimeMetadata, @@ -281,6 +282,67 @@ async function testMaxCandidatesCapsRankedOutputNotEvaluationOrder() { assert.equal(await topCardIdFor([good, bad]), 'card-good'); } +async function testLiveObjectiveOutputShapeAndPointConversion() { + const state = buildStubState({ + cards: [ + { + id: 'card-points', + userId: 'user-1', + issuer: 'Issuer', + label: 'Points Card', + network: 'VISA', + productSlug: null, + rewardRules: [ + { + id: 'rule-points', + cardId: 'card-points', + categoryKey: 'DINING', + rateType: 'POINTS_PER_DOLLAR', + rateValue: 5, + confidence: 1, + source: 'STATIC_CONFIG', + }, + ], + isCredit: false, + isActive: true, + isVirtual: false, + }, + ], + }); + const ctx = buildStubContext({ amountCents: 1_000 }); + const result = await solveDecision(state, ctx, { + candidateActionsOverride: [{ type: 'USE_CARD', cardId: 'card-points' }], + maxCandidates: 1, + }); + const decision = result.decisions[0]; + + assert.ok(decision); + assert.equal(decision.scoreUnit, OBJECTIVE_SCORE_UNIT); + assert.equal(decision.score, decision.objectiveUtilityCents); + assert.ok(Array.isArray(decision.scoreComponents)); + assert.equal(decision.scoreComponents.length > 0, true); + assert.equal( + decision.scoreComponents.every( + (component) => + typeof component.interpretation === 'string' && + component.interpretation.length > 0 + ), + true + ); + assert.ok( + decision.scoreComponents.some( + (component) => + component.key === 'reward_point_value' && component.utilityCents === 50 + ) + ); + assert.equal(result.trace.candidates[0]?.scoreUnit, OBJECTIVE_SCORE_UNIT); + assert.equal( + result.trace.candidates[0]?.objectiveUtilityCents, + result.trace.candidates[0]?.score + ); + assert.ok(Array.isArray(result.trace.candidates[0]?.scoreComponents)); +} + async function testDeterministicOrderingForEqualScores() { const state = buildStubState({ debts: [ @@ -1322,6 +1384,7 @@ function testGetEngineCapabilitiesDefaultsToUnavailable() { async function run() { await testSolveDecisionSorts(); await testMaxCandidatesCapsRankedOutputNotEvaluationOrder(); + await testLiveObjectiveOutputShapeAndPointConversion(); await testDeterministicOrderingForEqualScores(); await testSolveDecisionValidation(); await testSafeSolveDecisionSuccess(); diff --git a/tests/engine/optimality/admissibility-equivalence.spec.ts b/tests/engine/optimality/admissibility-equivalence.spec.ts index bf330fde..e529fd9a 100644 --- a/tests/engine/optimality/admissibility-equivalence.spec.ts +++ b/tests/engine/optimality/admissibility-equivalence.spec.ts @@ -7,6 +7,7 @@ import { evaluateConstraintsForDecision, formatConstraintTag, getHardConstraints, + OBJECTIVE_SCORE_UNIT, simulateAction, solveDecision, unavailable, @@ -260,6 +261,9 @@ function engineEvaluate( const decision: EngineDecision = { actionId: candidateKey(normalized), action, + objectiveUtilityCents: 0, + scoreUnit: OBJECTIVE_SCORE_UNIT, + scoreComponents: [], score: 0, reasons: [], projections, diff --git a/tests/node/engine-accounting-proof.test.ts b/tests/node/engine-accounting-proof.test.ts index 8a35a52b..da45e22b 100644 --- a/tests/node/engine-accounting-proof.test.ts +++ b/tests/node/engine-accounting-proof.test.ts @@ -19,6 +19,7 @@ import { createLoadedEngineCapabilities, type EngineState, } from '../../lib/engine/types.js'; +import { OBJECTIVE_SCORE_UNIT } from '../../lib/engine/objective/utility.js'; import { buildAccountingSnapshot, filterAccountingSafeDecisions, @@ -152,6 +153,9 @@ async function testFiltersUnsafeDecisions(): Promise { const safeDecision: EngineDecisionWithAccounting = { actionId: 'safe', action: { type: 'REJECT_PURCHASE' }, + objectiveUtilityCents: 1, + scoreUnit: OBJECTIVE_SCORE_UNIT, + scoreComponents: [], score: 1, reasons: [], projections: { buckets: [], debt: [], cash: { projectedLiquidCents: null, projectedOverdraftRisk: null } }, diff --git a/tests/node/engine-accounting-time-order.test.ts b/tests/node/engine-accounting-time-order.test.ts index e1b8e603..a5b97c2d 100644 --- a/tests/node/engine-accounting-time-order.test.ts +++ b/tests/node/engine-accounting-time-order.test.ts @@ -21,6 +21,7 @@ import { type EngineDecision, type EngineState, } from '../../lib/engine/types.js'; +import { OBJECTIVE_SCORE_UNIT } from '../../lib/engine/objective/utility.js'; import { attachAccountingProof, buildAccountingSnapshot, @@ -50,6 +51,9 @@ function buildDecision(actionType: EngineActionType): EngineDecision { return { actionId: `decision-${actionType}`, action: { type: actionType }, + objectiveUtilityCents: 1, + scoreUnit: OBJECTIVE_SCORE_UNIT, + scoreComponents: [], score: 1, reasons: [], projections: { buckets: [], debt: [], cash: { projectedLiquidCents: null, projectedOverdraftRisk: null } }, diff --git a/tests/node/engine-solver.test.js b/tests/node/engine-solver.test.js index b715ec0c..6af928bc 100644 --- a/tests/node/engine-solver.test.js +++ b/tests/node/engine-solver.test.js @@ -12,6 +12,7 @@ const { evaluateConstraintsForDecision, enforceHardConstraints, EngineError, + OBJECTIVE_SCORE_UNIT, createLoadedEngineCapabilities, createUnavailableEngineCapabilities, getEngineRuntimeMetadata, @@ -281,6 +282,67 @@ async function testMaxCandidatesCapsRankedOutputNotEvaluationOrder() { assert.equal(await topCardIdFor([good, bad]), 'card-good'); } +async function testLiveObjectiveOutputShapeAndPointConversion() { + const state = buildStubState({ + cards: [ + { + id: 'card-points', + userId: 'user-1', + issuer: 'Issuer', + label: 'Points Card', + network: 'VISA', + productSlug: null, + rewardRules: [ + { + id: 'rule-points', + cardId: 'card-points', + categoryKey: 'DINING', + rateType: 'POINTS_PER_DOLLAR', + rateValue: 5, + confidence: 1, + source: 'STATIC_CONFIG', + }, + ], + isCredit: false, + isActive: true, + isVirtual: false, + }, + ], + }); + const ctx = buildStubContext({ amountCents: 1_000 }); + const result = await solveDecision(state, ctx, { + candidateActionsOverride: [{ type: 'USE_CARD', cardId: 'card-points' }], + maxCandidates: 1, + }); + const decision = result.decisions[0]; + + assert.ok(decision); + assert.equal(decision.scoreUnit, OBJECTIVE_SCORE_UNIT); + assert.equal(decision.score, decision.objectiveUtilityCents); + assert.ok(Array.isArray(decision.scoreComponents)); + assert.equal(decision.scoreComponents.length > 0, true); + assert.equal( + decision.scoreComponents.every( + (component) => + typeof component.interpretation === 'string' && + component.interpretation.length > 0 + ), + true + ); + assert.ok( + decision.scoreComponents.some( + (component) => + component.key === 'reward_point_value' && component.utilityCents === 50 + ) + ); + assert.equal(result.trace.candidates[0]?.scoreUnit, OBJECTIVE_SCORE_UNIT); + assert.equal( + result.trace.candidates[0]?.objectiveUtilityCents, + result.trace.candidates[0]?.score + ); + assert.ok(Array.isArray(result.trace.candidates[0]?.scoreComponents)); +} + async function testDeterministicOrderingForEqualScores() { const state = buildStubState({ debts: [ @@ -1326,6 +1388,7 @@ function testGetEngineCapabilitiesDefaultsToUnavailable() { async function run() { await testSolveDecisionSorts(); await testMaxCandidatesCapsRankedOutputNotEvaluationOrder(); + await testLiveObjectiveOutputShapeAndPointConversion(); await testDeterministicOrderingForEqualScores(); await testSolveDecisionValidation(); await testSafeSolveDecisionSuccess(); diff --git a/tests/node/engine/optimality/admissibility-equivalence.spec.ts b/tests/node/engine/optimality/admissibility-equivalence.spec.ts index cd924baa..3cfd4318 100644 --- a/tests/node/engine/optimality/admissibility-equivalence.spec.ts +++ b/tests/node/engine/optimality/admissibility-equivalence.spec.ts @@ -7,6 +7,7 @@ import { evaluateConstraintsForDecision, formatConstraintTag, getHardConstraints, + OBJECTIVE_SCORE_UNIT, simulateAction, solveDecision, unavailable, @@ -260,6 +261,9 @@ function engineEvaluate( const decision: EngineDecision = { actionId: candidateKey(normalized), action, + objectiveUtilityCents: 0, + scoreUnit: OBJECTIVE_SCORE_UNIT, + scoreComponents: [], score: 0, reasons: [], projections, diff --git a/tests/node/objective-utility.test.ts b/tests/node/objective-utility.test.ts new file mode 100644 index 00000000..0ea0f1f7 --- /dev/null +++ b/tests/node/objective-utility.test.ts @@ -0,0 +1,45 @@ +import * as assert from 'node:assert/strict'; +import { + REWARD_POINT_VALUE_CENTS, + centsToUtilityCents, + rewardPointsToUtilityCents, + sumObjectiveUtility, + utilityCents, +} from '../../lib/engine/objective/utility.js'; + +function testRewardPointsUseExplicitValueMapping(): void { + assert.equal(rewardPointsToUtilityCents(1000), 1000); + assert.equal( + rewardPointsToUtilityCents(5000), + 5000 * REWARD_POINT_VALUE_CENTS + ); +} + +function testCashCentsStayCanonical(): void { + assert.equal(centsToUtilityCents(2500), 2500); +} + +function testTotalUsesObjectiveComponentUtilityCents(): void { + const total = sumObjectiveUtility([ + { + key: 'cash', + kind: 'cash_benefit', + utilityCents: utilityCents(1000), + interpretation: 'Cash benefit.', + }, + { + key: 'liquidity', + kind: 'liquidity_pressure', + utilityCents: utilityCents(-250), + interpretation: 'Liquidity penalty.', + }, + ]); + + assert.equal(total, 750); +} + +testRewardPointsUseExplicitValueMapping(); +testCashCentsStayCanonical(); +testTotalUsesObjectiveComponentUtilityCents(); + +process.stdout.write('objective utility semantics: ok\n'); diff --git a/tests/replay/index/engine@engine_behavior_v6__engine_input_v1__engine_candidate_space_v1__engine_accounting_v1.json b/tests/replay/index/engine@engine_behavior_v6__engine_input_v1__engine_candidate_space_v1__engine_accounting_v1.json new file mode 100644 index 00000000..c47758b5 --- /dev/null +++ b/tests/replay/index/engine@engine_behavior_v6__engine_input_v1__engine_candidate_space_v1__engine_accounting_v1.json @@ -0,0 +1,11 @@ +{ + "hashes": [ + "8f0cf4f6b3683f107427e15adb49485cfddd761980910879e8f4b1b916c4c9b8" + ], + "versions": { + "engineAccountingVersion": "engine_accounting_v1", + "engineBehaviorVersion": "engine_behavior_v6", + "engineCandidateSpaceVersion": "engine_candidate_space_v1", + "engineInputVersion": "engine_input_v1" + } +} diff --git a/tests/replay/objects/8f/0c/8f0cf4f6b3683f107427e15adb49485cfddd761980910879e8f4b1b916c4c9b8.json b/tests/replay/objects/8f/0c/8f0cf4f6b3683f107427e15adb49485cfddd761980910879e8f4b1b916c4c9b8.json new file mode 100644 index 00000000..071d5eec --- /dev/null +++ b/tests/replay/objects/8f/0c/8f0cf4f6b3683f107427e15adb49485cfddd761980910879e8f4b1b916c4c9b8.json @@ -0,0 +1,399 @@ +{ + "input": { + "__version": "engine_input_v1", + "balances": { + "cash": { + "liquidCents": null + } + }, + "buckets": [], + "cards": [ + { + "id": "card-1", + "isActive": true, + "isCredit": true, + "rewardRules": [] + } + ], + "constraints": { + "hard": { + "maxCardUtilization": null + } + }, + "debtCardLinks": [], + "debts": [], + "preferences": { + "customWeights": null, + "profileId": "BALANCED" + }, + "request": { + "amountCents": 1000, + "merchantCategoryKey": "DINING", + "surface": "web" + }, + "solver": { + "maxCandidates": 64, + "weightsOverride": null + } + }, + "meta": { + "redactionVersion": "replay_redaction_v1", + "source": "api/scan", + "surface": "web", + "timestampMs": 1769878005784, + "traceId": "scan-20260131-b08b1416e9ee", + "user": "user-e1e1" + }, + "output": { + "decisions": [ + { + "accounting": { + "proof": [], + "proposedTxns": [] + }, + "action": { + "altMerchantCategoryKey": "DINING", + "altMerchantName": "cheaper alternative", + "meta": { + "reasonHint": "ALT_MERCHANT_SAME_CATEGORY" + }, + "type": "SWITCH_MERCHANT" + }, + "actionId": "switch_merchant:DINING", + "components": { + "debtRelief": 0, + "rewards": 0, + "runway": 0 + }, + "constraintsBreached": [], + "objectiveUtilityCents": 0, + "projections": { + "buckets": [], + "cash": { + "projectedLiquidCents": null, + "projectedOverdraftRisk": null + }, + "debt": [] + }, + "reasons": [ + "Suggests an alternate merchant in the same category to save cost." + ], + "score": 0, + "scoreComponents": [], + "scoreUnit": "utility_usd_cents" + }, + { + "accounting": { + "proof": [], + "proposedTxns": [] + }, + "action": { + "delayDays": 3, + "type": "DELAY_PURCHASE" + }, + "actionId": "delay_purchase:3", + "components": { + "debtRelief": 0, + "rewards": 0, + "runway": -10 + }, + "constraintsBreached": [], + "objectiveUtilityCents": -10, + "projections": { + "buckets": [], + "cash": { + "projectedLiquidCents": null, + "projectedOverdraftRisk": null + }, + "debt": [] + }, + "reasons": [ + "Applied bounded liquidity-pressure heuristic in objectiveUtilityCents.", + "Delays purchase by 3 day(s) to reduce near-term risk." + ], + "score": -10, + "scoreComponents": [ + { + "boundedHeuristic": true, + "interpretation": "Bounded non-utility penalty used to discourage fragile near-term liquidity outcomes from delaying the purchase.", + "key": "delay_purchase_liquidity_pressure", + "kind": "liquidity_pressure", + "utilityCents": -10 + } + ], + "scoreUnit": "utility_usd_cents" + }, + { + "accounting": { + "proof": [], + "proposedTxns": [] + }, + "action": { + "delayDays": 7, + "type": "DELAY_PURCHASE" + }, + "actionId": "delay_purchase:7", + "components": { + "debtRelief": 0, + "rewards": 0, + "runway": -10 + }, + "constraintsBreached": [], + "objectiveUtilityCents": -10, + "projections": { + "buckets": [], + "cash": { + "projectedLiquidCents": null, + "projectedOverdraftRisk": null + }, + "debt": [] + }, + "reasons": [ + "Applied bounded liquidity-pressure heuristic in objectiveUtilityCents.", + "Delays purchase by 7 day(s) to reduce near-term risk." + ], + "score": -10, + "scoreComponents": [ + { + "boundedHeuristic": true, + "interpretation": "Bounded non-utility penalty used to discourage fragile near-term liquidity outcomes from delaying the purchase.", + "key": "delay_purchase_liquidity_pressure", + "kind": "liquidity_pressure", + "utilityCents": -10 + } + ], + "scoreUnit": "utility_usd_cents" + }, + { + "accounting": { + "proof": [], + "proposedTxns": [] + }, + "action": { + "meta": { + "reasonHint": "USER_CAN_DECLINE" + }, + "type": "REJECT_PURCHASE" + }, + "actionId": "reject_purchase", + "components": { + "debtRelief": 0, + "rewards": 0, + "runway": -50 + }, + "constraintsBreached": [], + "objectiveUtilityCents": -50, + "projections": { + "buckets": [], + "cash": { + "projectedLiquidCents": null, + "projectedOverdraftRisk": null + }, + "debt": [] + }, + "reasons": [ + "Applied bounded liquidity-pressure heuristic in objectiveUtilityCents.", + "Recommends skipping this purchase to protect constraints." + ], + "score": -50, + "scoreComponents": [ + { + "boundedHeuristic": true, + "interpretation": "Bounded non-utility penalty used to represent the friction of rejecting the purchase while protecting constraints.", + "key": "reject_purchase_liquidity_pressure", + "kind": "liquidity_pressure", + "utilityCents": -50 + } + ], + "scoreUnit": "utility_usd_cents" + } + ], + "state": { + "buckets": [], + "capabilities": { + "debt": { + "available": true, + "reason": "loaded" + }, + "essentiality": { + "available": true, + "reason": "loaded" + }, + "liquidCash": { + "available": true, + "reason": "loaded" + }, + "utilization": { + "available": true, + "reason": "loaded" + } + }, + "cards": [ + { + "creditLimitCents": 0, + "currentBalanceCents": null, + "id": "card-1", + "isActive": true, + "isCredit": true, + "isVirtual": false, + "issuer": "unknown", + "label": "card:card-1", + "last4": null, + "linkedDebtId": null, + "network": "OTHER", + "productSlug": null, + "rewardRules": [], + "userId": "user-e1e1" + } + ], + "cash": { + "kind": "available", + "value": { + "liquidCents": null, + "nextPaycheckDateMs": null, + "nextPaycheckNetCents": null + } + }, + "constraints": { + "hard": { + "maxCardUtilization": null, + "minEssentialCoverageDays": 0 + }, + "soft": { + "avoidInterest": false, + "avoidNewDebt": false + } + }, + "debts": { + "kind": "available", + "value": [] + }, + "preferences": { + "profileId": "BALANCED" + }, + "scheduledPaydowns": { + "kind": "unavailable" + }, + "userId": "user-e1e1", + "world": { + "baseInterestRate": null, + "inflationEstimate": null + } + }, + "trace": { + "candidates": [ + { + "action": { + "altMerchantCategoryKey": "DINING", + "altMerchantName": "cheaper alternative", + "meta": { + "reasonHint": "ALT_MERCHANT_SAME_CATEGORY" + }, + "type": "SWITCH_MERCHANT" + }, + "components": { + "debtRelief": 0, + "rewards": 0, + "runway": 0 + }, + "constraintsBreached": [], + "objectiveUtilityCents": 0, + "score": 0, + "scoreComponents": [], + "scoreUnit": "utility_usd_cents" + }, + { + "action": { + "delayDays": 3, + "type": "DELAY_PURCHASE" + }, + "components": { + "debtRelief": 0, + "rewards": 0, + "runway": -10 + }, + "constraintsBreached": [], + "objectiveUtilityCents": -10, + "score": -10, + "scoreComponents": [ + { + "boundedHeuristic": true, + "interpretation": "Bounded non-utility penalty used to discourage fragile near-term liquidity outcomes from delaying the purchase.", + "key": "delay_purchase_liquidity_pressure", + "kind": "liquidity_pressure", + "utilityCents": -10 + } + ], + "scoreUnit": "utility_usd_cents" + }, + { + "action": { + "delayDays": 7, + "type": "DELAY_PURCHASE" + }, + "components": { + "debtRelief": 0, + "rewards": 0, + "runway": -10 + }, + "constraintsBreached": [], + "objectiveUtilityCents": -10, + "score": -10, + "scoreComponents": [ + { + "boundedHeuristic": true, + "interpretation": "Bounded non-utility penalty used to discourage fragile near-term liquidity outcomes from delaying the purchase.", + "key": "delay_purchase_liquidity_pressure", + "kind": "liquidity_pressure", + "utilityCents": -10 + } + ], + "scoreUnit": "utility_usd_cents" + }, + { + "action": { + "meta": { + "reasonHint": "USER_CAN_DECLINE" + }, + "type": "REJECT_PURCHASE" + }, + "components": { + "debtRelief": 0, + "rewards": 0, + "runway": -50 + }, + "constraintsBreached": [], + "objectiveUtilityCents": -50, + "score": -50, + "scoreComponents": [ + { + "boundedHeuristic": true, + "interpretation": "Bounded non-utility penalty used to represent the friction of rejecting the purchase while protecting constraints.", + "key": "reject_purchase_liquidity_pressure", + "kind": "liquidity_pressure", + "utilityCents": -50 + } + ], + "scoreUnit": "utility_usd_cents" + } + ], + "contextSummary": { + "amountCents": 1000, + "merchantCategoryKey": "DINING", + "surface": "web" + }, + "diagnostics": [], + "engineVersion": "v0.2.0", + "stateSummary": { + "bucketCount": 0, + "cardCount": 1, + "debtCount": 0 + }, + "weights": { + "debtRelief": 1, + "rewards": 1, + "runway": 1 + } + } + } +} From 616ac3b5d5de7f3740150739cd37a4cc6bd065e1 Mon Sep 17 00:00:00 2001 From: Mous <156965421+div0rce@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:01:07 -0400 Subject: [PATCH 2/2] chore(engine-freeze): bump behavior version for objective semantics --- scripts/guardrails/engine-freeze.policy.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/guardrails/engine-freeze.policy.json b/scripts/guardrails/engine-freeze.policy.json index f09867f1..fc61ebed 100644 --- a/scripts/guardrails/engine-freeze.policy.json +++ b/scripts/guardrails/engine-freeze.policy.json @@ -6,7 +6,7 @@ ] }, "engineVersions": { - "behavior": "engine_behavior_v5", + "behavior": "engine_behavior_v6", "input": "engine_input_v1", "candidateSpace": "engine_candidate_space_v1", "accounting": "engine_accounting_v1"