Context
packages/loopover-engine/src/reward-risk.ts's opportunityCompetitionFactor (line 900) was fixed by #7529 (after a NaN-propagation bug) to delegate to the pure mirror computeOpportunityCompetition (./opportunity-competition.js) instead of hand-duplicating its arithmetic — its own comment says: "Delegating -- rather than duplicating the guards -- is what stops the two drifting apart again."
The very next function in the same file, opportunityFreshnessFactor (line 911), was never given the same treatment. It hand-reimplements issueAgeDays/pickIssueTimestamp/isParseableIssueTimestamp instead of importing and delegating to computeOpportunityFreshness (./opportunity-freshness.js), which already exists in the same package with the identical round4/clamp/exp(-age/20) formula.
Worse: computeOpportunityFreshness's signature takes an injected nowMs clock (this codebase's pervasive "no bare Date.now()" purity discipline, used for testability everywhere else), but reward-risk.ts's hand-duplicated issueAgeDays calls Date.now() directly — so buildRepoRewardRisk (which calls opportunityFreshnessFactor(args.issues) with no clock argument) is not actually deterministic the way the rest of the module claims to be. Any future drift between the two copies — like the exact NaN bug #7529 just fixed for its sibling — has no guard against it today.
⚠️ Required pattern — read opportunityCompetitionFactor's post-#7529 delegation shape in the same file before starting. Convert opportunityFreshnessFactor to delegate to computeOpportunityFreshness from ./opportunity-freshness.js, threading through an injectable nowMs clock the same way the freshness module's own signature already expects — do not hand-duplicate the arithmetic a second time.
Requirements
opportunityFreshnessFactor must delegate to computeOpportunityFreshness instead of hand-reimplementing issueAgeDays/pickIssueTimestamp/isParseableIssueTimestamp.
- No more bare
Date.now() call in this path — the clock must be injectable, mirroring the rest of this module's purity discipline.
buildRepoRewardRisk's call site updates accordingly (threading a clock through, or defaulting to Date.now() only at the call boundary, not inside the pure calculator).
- Output must remain arithmetically identical for finite inputs versus the current implementation (this is a refactor for correctness/consistency, not a formula change).
Deliverables
Test Coverage Requirements
packages/loopover-engine/** — 99%+ Codecov patch target, both branches. Add a test asserting opportunityFreshnessFactor produces identical output to a direct computeOpportunityFreshness call for the same inputs, and a determinism test (same inputs + injected clock → same output, no flakiness from real time).
Expected Outcome
opportunityFreshnessFactor is deterministic and can't silently drift from computeOpportunityFreshness's formula the way opportunityCompetitionFactor briefly did before #7529.
Links & Resources
Context
packages/loopover-engine/src/reward-risk.ts'sopportunityCompetitionFactor(line 900) was fixed by #7529 (after a NaN-propagation bug) to delegate to the pure mirrorcomputeOpportunityCompetition(./opportunity-competition.js) instead of hand-duplicating its arithmetic — its own comment says: "Delegating -- rather than duplicating the guards -- is what stops the two drifting apart again."The very next function in the same file,
opportunityFreshnessFactor(line 911), was never given the same treatment. It hand-reimplementsissueAgeDays/pickIssueTimestamp/isParseableIssueTimestampinstead of importing and delegating tocomputeOpportunityFreshness(./opportunity-freshness.js), which already exists in the same package with the identicalround4/clamp/exp(-age/20)formula.Worse:
computeOpportunityFreshness's signature takes an injectednowMsclock (this codebase's pervasive "no bareDate.now()" purity discipline, used for testability everywhere else), butreward-risk.ts's hand-duplicatedissueAgeDayscallsDate.now()directly — sobuildRepoRewardRisk(which callsopportunityFreshnessFactor(args.issues)with no clock argument) is not actually deterministic the way the rest of the module claims to be. Any future drift between the two copies — like the exact NaN bug #7529 just fixed for its sibling — has no guard against it today.Requirements
opportunityFreshnessFactormust delegate tocomputeOpportunityFreshnessinstead of hand-reimplementingissueAgeDays/pickIssueTimestamp/isParseableIssueTimestamp.Date.now()call in this path — the clock must be injectable, mirroring the rest of this module's purity discipline.buildRepoRewardRisk's call site updates accordingly (threading a clock through, or defaulting toDate.now()only at the call boundary, not inside the pure calculator).Deliverables
opportunityFreshnessFactordelegates tocomputeOpportunityFreshness.Date.now()remains inside the pure calculator path; the clock is injected.reward-risk.tstests continue to pass (values unchanged for finite inputs).Test Coverage Requirements
packages/loopover-engine/**— 99%+ Codecov patch target, both branches. Add a test assertingopportunityFreshnessFactorproduces identical output to a directcomputeOpportunityFreshnesscall for the same inputs, and a determinism test (same inputs + injected clock → same output, no flakiness from real time).Expected Outcome
opportunityFreshnessFactoris deterministic and can't silently drift fromcomputeOpportunityFreshness's formula the wayopportunityCompetitionFactorbriefly did before #7529.Links & Resources
packages/loopover-engine/src/reward-risk.ts(opportunityCompetitionFactorline 900,opportunityFreshnessFactorline 911)packages/loopover-engine/src/opportunity-freshness.ts(computeOpportunityFreshness, the pure mirror to delegate to)