Skip to content

aggregator(forecast): derive epic ETA bands and explainable stall risk (#3982) - #5236

Merged
dezren39 merged 4 commits into
mainfrom
queue/8962-3982-epic-eta
Nov 4, 2026
Merged

dezren39 merged 4 commits into
mainfrom
queue/8962-3982-epic-eta

Conversation

@dezren39

@dezren39 dezren39 commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Implements #3982 (task 8962): pure forecast derivations over the #3981 item-history cutoffs, per the forecasting design §1, §3, §4.

What

  • pm-stack/aggregator/src/forecast.ts — deriveEpicEta and deriveStallRisk, both pure and deterministic, model versions forecast/v1 and stall-risk/v1.
  • ETA: one direct native child issue closure = one unit (merged PRs never count; a reopened child is remaining work). Complete observed weekly buckets only — zero-completion weeks count, incomplete cutoffs are excluded rather than read as zero. Trailing 4-week primary / 8-week comparison windows; P50 from median pace, P80 from lower-quartile pace. Below 3 completions / 4 complete weeks: a clearly named borrowed baseline (low confidence) or not_enough_history with no dates; zero pace in a full primary window is no_measurable_pace. Effort stays explicit — remaining work is reported per effort label, unsized counted as unsized, l/xl flagged open-ended; no invented precision.
  • Stall risk: 0–100 rule score with reason codes and timestamps — blocked-edge first-observed lower-bound age ≥7d (+30, labelled a lower bound), updatedAt activity proxy ≥7d (+20, labelled a proxy), queue heartbeat missed ×2 / lease near expiry (+30), ≥2 requeues in 14d (+20). Unsupplied signals stay unknown, contribute nothing, and reduce evidence coverage. Score ≥60 is informational "at risk" only; escalatesToNeedsYou is always false.
  • pm-stack/aggregator/test/forecast-3982.test.ts — 16 cases: missing history, incomplete-week exclusion, steady pace, zero pace, sparse sample, borrowed baseline, reopen, PR exclusion, effort explicitness, and each stall signal including the 60-point at-risk boundary and the 100 cap.

Tests

  • Laptop: bun test test/forecast-3982.test.ts test/item-history.test.ts — 29 pass, 0 fail.
  • bun build src/forecast.ts bundles clean. bunx tsc --noEmit cannot run in the fresh worktree (no node_modules installed — Cannot find type definition file for 'bun', pre-existing environment gap, no forecast-specific errors); CI typecheck will cover it.

Refs #3982 — issue stays open until this merges.


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

#3982)

Pure derivations over the #3981 item-history cutoffs (design sections 1, 3, 4):
- deriveEpicEta: direct native child issue closures as units (PRs never count,
  reopened children are remaining work), complete weekly buckets only including
  zero weeks, trailing 4-week primary / 8-week comparison windows, P50/P80 dates
  from median / lower-quartile pace, evidence-grade confidence, named borrowed
  baseline or not_enough_history / no_measurable_pace with no dates below the
  3-completion / 4-week floor, effort kept explicit (unsized + open-ended named).
- deriveStallRisk: versioned 0-100 rule score (blocked-edge lower-bound age +30,
  updatedAt activity proxy +20, queue heartbeat +30, requeues +20), reason codes
  with timestamps, unknown signals contribute nothing and reduce coverage,
  >=60 is informational at-risk only and never escalates by itself.

Tests: 16 new cases in test/forecast-3982.test.ts (missing history, zero pace,
reopen, sparse, borrowed baseline, PR exclusion, effort, each stall signal);
29/29 pass with the existing item-history tests on the laptop.
@dezren39
dezren39 requested a review from a team as a code owner November 4, 2026 01:37
@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 7 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2f10a1d1-f7e8-465f-9749-b293e03c0a88
Commits

Reviewing files that changed from the base of the PR and between 1391b59 and ee57a97.

Files selected for processing: 2
  • pm-stack/aggregator/src/forecast.ts
  • pm-stack/aggregator/test/forecast-3982.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-04T03:52:16.867555Z ee57a97 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c80fe80387

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pm-stack/aggregator/src/forecast.ts Outdated
Comment on lines +285 to +288
const paceP80 = Math.max(lowerQuartile(paceSeries), Number.EPSILON);
// Whole weeks, rounded up: a partial week is not a completion date.
result.p50 = addDays(asOf, Math.ceil(remainingUnits / paceP50) * 7);
result.p80 = addDays(asOf, Math.ceil(remainingUnits / paceP80) * 7);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle zero conservative pace without constructing an invalid date

With four complete weekly buckets such as [0, 1, 1, 1] and at least one remaining child, the local evidence floor passes and the median pace is positive, but lowerQuartile returns zero. Substituting Number.EPSILON produces roughly 4.5 quadrillion weeks per remaining issue, exceeding JavaScript's date range and causing addDays(...).toISOString() to throw RangeError. A single observed zero-completion week can therefore crash an otherwise eligible forecast. Handle an unmeasurable conservative pace explicitly rather than converting it into a finite denominator.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 40ed243 (task 8962, lane code-h): the EPSILON substitution is gone. A zero lower-quartile pace now leaves p80 explicitly null with a limitation naming the unmeasurable conservative pace, and a new etaDate guard returns null for horizons outside the representable Date range instead of throwing. Regression test: "zero lower-quartile pace: P50 present, P80 explicitly unmeasurable, no crash" (primary weeks [0,1,1,1]).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 40ed243 (already on this PR's head before this reply): the EPSILON substitution is gone — a zero lower-quartile pace now returns p80: null with an explicit limitation, and etaDate() guards the representable Date range so no pace can make toISOString() throw. Covered by the test "zero lower-quartile pace: P50 present, P80 explicitly unmeasurable, no crash". — task 8962, lane code-e

Comment thread pm-stack/aggregator/src/forecast.ts Outdated
latest.items
.filter(
(i) =>
i.kind !== "pr" && (i.parentRef === input.subjectRef || i.epicRef === input.subjectRef),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict inferred epic scope to direct native children

When an epic contains nested sub-issues, this predicate includes grandchildren as independent forecast units because epicRef represents ancestor membership: resolveEpicRefs in projects.ts walks the entire parent chain. For an epic with child A and A's child B, both A and B enter the remaining/completed counts and throughput series, contrary to the direct-child unit specified in the module and forecasting design §1. Infer direct children from parentRef rather than treating recursive epicRef membership as a direct relationship.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 40ed243 (task 8962, lane code-h): inferred scope now requires parentRef === subject only. epicRef is ancestor membership (resolveEpicRefs walks the whole parent chain), so it no longer counts grandchildren as independent units; the EpicEtaInput doc says the same. Regression test: "inferred scope counts direct children only, not grandchildren via epicRef".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 40ed243: inferred scope now derives direct native children via parentRef === subject (epicRef ancestor membership is no longer used for scope), exactly as this finding requires. Covered by "inferred scope counts direct children only, not grandchildren via epicRef". — task 8962, lane code-e

Comment thread pm-stack/aggregator/src/forecast.ts Outdated
Comment on lines +139 to +141
const complete = completeCutoffs(input.cutoffs);
const latest = complete[complete.length - 1];
const asOf = input.asOf ?? latest?.cutoffAt ?? new Date(0).toISOString();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude observations later than the requested as-of instant

When asOf precedes the newest stored cutoff, the function still selects the newest item states and computes throughput from the entire history, then anchors those future-derived results to the earlier instant. For example, a child closed after the requested date is already counted as completed in that date's forecast. This leaks future outcomes into historical forecasts and violates the rolling-origin evaluation requirement in forecasting design §5. Restrict the history used for scope, transitions, and coverage to cutoffs at or before asOf; deriveStallRisk has the same unrestricted-history behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 40ed243 (task 8962, lane code-h): both deriveEpicEta and deriveStallRisk now scope to cutoffs at or before asOf before selecting the latest cutoff, computing transitions (weeklyTransitions over the scoped set), coverage, and blocker episodes — future outcomes no longer leak into historical forecasts. Regression test: "asOf excludes later cutoffs: a closure after asOf is not yet a completion".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 40ed243: deriveEpicEta and deriveStallRisk scope the history to cutoffs at or before asOf before selecting the latest state or computing transitions, so future outcomes no longer leak into historical forecasts. Covered by "asOf excludes later cutoffs: a closure after asOf is not yet a completion". (The representation flaw inside that comparison is the round-2 finding, fixed separately in e10e253.) — task 8962, lane code-e

Comment on lines +378 to +380
for (const blocker of item.blockedBy) {
const first = blockerFirstObserved(input.cutoffs, input.ref, blocker);
if (first && first.lowerBoundAgeDays >= 7) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude resolved blockers from blocked-edge risk

When an item retains a blockedBy relation for at least seven days but the referenced blocker is now closed in the latest cutoff, this loop still assigns 30 points and reports an unresolved blocker. History capture preserves raw relations, so their presence does not establish that the blocker remains open: graph.ts explicitly treats closed/merged in-pool blockers as resolved, and graph.test.ts covers this persistent-edge scenario. Combined with a firing heartbeat signal, this incorrectly reaches the at-risk threshold. Check the blocker's latest state before assigning blocked-edge points, while preserving the existing unresolved treatment for out-of-pool blockers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 40ed243 (task 8962, lane code-h): the blocked-edge loop now checks the blocker at the latest cutoff — a blocker held closed/merged there is resolved and contributes nothing (matching graph.ts blockerUnresolved), while an out-of-pool blocker still counts as unresolved. Regression test: "a blocker closed at the latest cutoff is resolved: no blocked-edge points".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 40ed243: the blocked-edge loop now skips blockers the latest cutoff holds as closed/merged (resolved, matching graph.ts), while absent-from-pool blockers stay unresolved. Covered by "a blocker closed at the latest cutoff is resolved: no blocked-edge points". — task 8962, lane code-e

- P1: zero lower-quartile pace no longer becomes an EPSILON denominator (crashed toISOString via an out-of-range date); P80 is explicitly null with a limitation, and etaDate guards unrepresentable horizons.
- P2: inferred epic scope counts direct native children only (parentRef), not grandchildren via ancestor epicRef.
- P2: deriveEpicEta and deriveStallRisk restrict scope/transitions/coverage to cutoffs at or before asOf (rolling-origin honesty).
- P2: blocked-edge risk skips blockers the latest cutoff holds closed/merged (resolved, matching graph.ts); out-of-pool blockers stay unresolved.
- Regression tests for all four findings; targeted suite 20/20 on the laptop.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40ed243f24

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pm-stack/aggregator/src/forecast.ts Outdated
// cutoffs are future outcomes and must not leak into scope,
// transitions, or coverage.
const scopedCutoffs = input.asOf
? input.cutoffs.filter((c) => c.cutoffAt <= input.asOf!)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compare as-of timestamps as instants rather than strings

When cutoff and asOf timestamps use different valid ISO representations, this string comparison selects the wrong history. Fresh evidence after the earlier filtering fix is that isItemHistoryCutoff accepts optional fractional seconds and timezone offsets through isIsoDate: cutoff 2026-11-02T00:00:00Z is excluded for the identical instant 2026-11-02T00:00:00.000Z, while cutoff 2026-11-02T00:00:00-05:00 is included for asOf: "2026-11-02T01:00:00Z" despite occurring four hours later. This can discard the latest observation or leak future outcomes into historical forecasts. Compare parsed instants in both this filter and the equivalent deriveStallRisk filter.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in e10e253: asOf scoping in both derivations and the completeCutoffs sort now compare timestamps as instants via Date.parse (atOrBefore/instantOrder helpers, string-order fallback only for unparseable values). Three new regression tests cover the equal-instant fractional-second case and the -05:00 offset case for both deriveEpicEta and deriveStallRisk. bun test forecast-3982: 23 pass, 0 fail. — task 8962, lane code-e

…gs (#3982)

Codex round-2 finding on PR #5236 (forecast.ts:155): the asOf scoping filter and cutoff sort compared ISO timestamps as strings, so equivalent instants in different representations (fractional seconds, timezone offsets) were scoped/sorted wrong. Add atOrBefore/instantOrder helpers (Date.parse, string fallback for unparseable values), use them in both deriveEpicEta and deriveStallRisk scoping and in completeCutoffs sorting. Three regression tests cover the equal-instant and offset cases for both derivations. bun test forecast-3982: 23 pass.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e10e253be6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pm-stack/aggregator/src/forecast.ts Outdated
resolvedBlockers++;
continue;
}
const first = blockerFirstObserved(scopedCutoffs, input.ref, blocker);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound blocker-age history to the effective evaluation instant

When asOf is omitted and an incomplete cutoff follows the latest complete cutoff, the result uses the complete cutoff's timestamp but this call still receives the later history. blockerFirstObserved measures age through the last supplied cutoff regardless of its coverage. For example, a newly observed edge at a complete September 7 cutoff followed by an unavailable September 14 cutoff receives 30 points in a result dated September 7. Unlike the earlier explicit-asOf issue, the remaining evidence is the unfiltered default path at lines 427–432. Bound the helper's history to the effective asOf so later unavailable observations cannot prematurely trigger blocked-edge risk.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in the commit pushed on top of this PR (task 8962, lane code-a): deriveStallRisk now bounds the history handed to blockerFirstObserved to the effective asOf (boundedCutoffs = scopedCutoffs.filter(atOrBefore(cutoffAt, asOf))), so when asOf is omitted, later unavailable cutoffs no longer age an edge inside a result dated at the latest complete cutoff. Regression test "default asOf bounds blocker age to the latest complete cutoff": edge first observed at the complete Sep 7 cutoff with an unavailable Sep 14 cutoff also listing it now contributes 0 (was 30). bun test test/forecast-3982.test.ts on the laptop: 25 pass, 0 fail.

.filter((i) => i.kind !== "pr" && i.parentRef === input.subjectRef)
.map((i) => i.ref);
const childSet = new Set(childRefs);
const children = latest.items.filter((i) => childSet.has(i.ref) && i.kind !== "pr");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unknown children from the explicitly requested scope

When explicit childRefs contains a child absent from the latest complete cutoff, this intersection silently drops that child rather than recording its state as unknown. For example, with requested children [A, B] and only closed A present in the latest curated pool, the function reports zero remaining units and returns p50 = p80 = asOf through the nothing-left branch, although B's completion was never observed. A complete source cutoff establishes coverage of the fetched pool, not the presence or completion of every explicitly requested child. Report missing requested refs as a scope limitation and avoid declaring the subject complete while their states are unknown.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in the commit pushed on top of this PR (task 8962, lane code-a): explicitly requested childRefs absent from the latest complete cutoff are now computed as missingRefs, reported as a scope limitation naming the refs ("state unknown"), and block the nothing-left branch — when every observed child is complete but requested children are unobserved, the result is not_enough_history with null p50/p80 instead of declaring the subject complete. Regression test "explicit childRefs absent from the latest cutoff are unknown, not complete" covers the [A closed, B unobserved] case plus the observed-only control (still p50 = p80 = asOf). bun test test/forecast-3982.test.ts on the laptop: 25 pass, 0 fail.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ee57a979ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +341 to +345
const paceP50 = median(paceSeries);
if (paceP50 <= 0) {
result.status = "no_measurable_pace";
limitations.push("observed weekly pace is zero in the primary window; no date is returned");
return result;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Estimate completion duration across cumulative weekly buckets

When four complete primary weeks contain [0, 0, 0, 4] closures and work remains, the local evidence floor passes, but the median weekly bucket is zero, so this branch returns no_measurable_pace and omits both dates despite positive measured throughput. This prevents forecasts for batch-delivery scopes with sufficient evidence. The forecasting design §1 specifies resampling completion buckets to estimate remaining-work duration; accumulating those sampled buckets produces finite completion-time percentiles even when most individual weeks have zero closures. Estimate that duration distribution rather than treating a zero single-week median as no measured pace.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Dispositioned as a tracked follow-up rather than fixed in this PR (task 8962, lane code-a). The finding is correct against the design: §1 of doc/2026-11-01T21-07-00Z_design_pm-stack_forecasting.md (line 36) specifies a resampling estimator over complete weekly buckets, and the median/lower-quartile pace in this PR is a simplification that returns no_measurable_pace for bursty series like [0, 0, 0, 4]. Replacing the estimator is a model change — it alters the pinned "zero lower-quartile pace → P80 unmeasurable" behavior and raises a FORECAST_MODEL_VERSION question — so it is filed as its own issue with acceptance criteria (deterministic seeded resampling, bursty-case forecast, truly-zero pace still no_measurable_pace, pinned-test updates, version decision): issue #5274. This PR keeps the remaining findings fixed (rounds 1–3) and merges with the estimator limitation tracked, not silent.

@dezren39
dezren39 merged commit eb36246 into main Nov 4, 2026
2 of 3 checks passed
@dezren39
dezren39 deleted the queue/8962-3982-epic-eta branch November 4, 2026 04:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant