Skip to content

feat: source-priority tiebreaker in affiliation timeline builder (CM-1106)#4055

Open
skwowet wants to merge 6 commits intomainfrom
improve/CM-1106
Open

feat: source-priority tiebreaker in affiliation timeline builder (CM-1106)#4055
skwowet wants to merge 6 commits intomainfrom
improve/CM-1106

Conversation

@skwowet
Copy link
Copy Markdown
Collaborator

@skwowet skwowet commented Apr 24, 2026

Adds source-aware tiebreaking to selectPrimaryWorkExperience in both the affiliation timeline builder and the public API path.

When multiple dated memberOrganizations rows cover the same day, the winner is now decided by source tier before falling through to the existing member-count and date-range heuristics:

ui → email-domain → enrichment-* → anything else

Source priority is only applied to dated rows, so undated rows remain last-resort regardless of source.


Note

Medium Risk
Changes the tie-breaking logic used to pick a primary affiliation when multiple dated organizations overlap, which can alter computed timelines and downstream affiliation-driven behavior. Risk is moderate because it affects core resolution logic but is bounded by explicit source tiers and added test coverage.

Overview
Adds a source-aware tiebreaker when choosing the “winning” organization for overlapping dated work experiences, prioritizing ui over email-domain over enrichment-* before falling back to member-count and date-range heuristics.

To support this, the API affiliation query now includes memberOrganizations.source, a shared helper (getMemberOrganizationSourceRank) is introduced in @crowd/common, and tests are extended to cover the new source-priority cases (including ensuring undated rows are unaffected).

Reviewed by Cursor Bugbot for commit 2004aa2. Bugbot is set up for automated code reviews on this repo. Configure here.

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
@skwowet skwowet self-assigned this Apr 24, 2026
Copilot AI review requested due to automatic review settings April 24, 2026 12:14
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Conventional Commits FTW!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds source-aware tie-breaking to the affiliation timeline builder’s primary work-experience selection so that overlapping dated work experiences prefer higher-trust sources (ui > email-domain > enrichment-* > others) before existing heuristics.

Changes:

  • Introduces getMemberOrganizationSourceRank in @crowd/common to rank member-organization sources.
  • Applies source-tier tie-breaking in selectPrimaryWorkExperience when multiple dated rows overlap.
  • Includes mo."source" in the memberOrganizations timeline query so source-based selection is possible.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
services/libs/data-access-layer/src/member-organization-affiliation/index.ts Adds source-tier tie-break step and selects source from DB for timeline building.
services/libs/common/src/member.ts Adds a shared helper for ranking member-organization sources.
Comments suppressed due to low confidence (1)

services/libs/data-access-layer/src/member-organization-affiliation/index.ts:99

  • memberOrgsOnly is filtered by 'segmentId' in row, but segmentId is what distinguishes manual affiliations (IManualAffiliationData). In this branch manual affiliations have already been returned early, so this filter will always be empty and the memberCount tiebreaker will never run. Filter for actual member-organization rows instead (e.g., exclude items with segmentId, or use a type guard on MemberOrganizationWithOverrides).
      // 3. get the two orgs with the most members, and return the one with the most members if there's no draw
      // only compare member orgs (manual affiliations don't have memberCount)
      const memberOrgsOnly = orgs.filter(
        (row: AffiliationItem) => 'segmentId' in row && !!row.segmentId,
      ) as MemberOrganizationWithOverrides[]
      if (memberOrgsOnly.length >= 2) {
        const sortedByMembers = memberOrgsOnly.sort((a, b) => b.memberCount - a.memberCount)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread services/libs/common/src/member.ts Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 25bcf9a. Configure here.

skwowet added 2 commits April 24, 2026 23:38
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 24, 2026 18:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

services/libs/data-access-layer/src/member-organization-affiliation/index.ts:97

  • memberOrgsOnly is intended to exclude manual affiliations (which don’t have memberCount), but the current predicate ('segmentId' in row && !!row.segmentId) actually selects manual affiliations. Since MemberOrganizationWithOverrides (IMemberOrganization) doesn’t have segmentId, this filter will usually return an empty array and the member-count tiebreaker will never run. Consider filtering by the absence of segmentId (or using a proper type guard) so member orgs are compared by memberCount as intended.
      // 3. get the two orgs with the most members, and return the one with the most members if there's no draw
      // only compare member orgs (manual affiliations don't have memberCount)
      const memberOrgsOnly = orgs.filter(
        (row: AffiliationItem) => 'segmentId' in row && !!row.segmentId,
      ) as MemberOrganizationWithOverrides[]

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread services/libs/data-access-layer/src/affiliations/index.ts Outdated
Comment thread services/libs/data-access-layer/src/affiliations/index.ts
@skwowet skwowet changed the title feat: source-priority tiebreaker in affiliation timeline builder (CM-1106) feat: source-priority tiebreaker in affiliation timeline builder (CM-1106) Apr 24, 2026
skwowet added 3 commits April 25, 2026 01:13
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 24, 2026 20:23
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

services/libs/data-access-layer/src/member-organization-affiliation/index.ts:98

  • The memberOrgsOnly filter is inverted: it selects rows that have segmentId (manual affiliations), but this branch only runs when there are no manual affiliations, so memberOrgsOnly will always be empty and the memberCount tiebreaker never executes. Filter for member-organization rows instead (e.g., !('segmentId' in row) or 'memberCount' in row) so memberCount comparison works as intended.
      // 3. get the two orgs with the most members, and return the one with the most members if there's no draw
      // only compare member orgs (manual affiliations don't have memberCount)
      const memberOrgsOnly = orgs.filter(
        (row: AffiliationItem) => 'segmentId' in row && !!row.segmentId,
      ) as MemberOrganizationWithOverrides[]
      if (memberOrgsOnly.length >= 2) {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +86 to +90
const sourceRank = (row: AffiliationItem) =>
getMemberOrganizationSourceRank((row as MemberOrganizationWithOverrides).source)
const bestRank = Math.min(...withDates.map(sourceRank))
orgs = withDates.filter((row) => sourceRank(row) === bestRank)
if (orgs.length === 1) return orgs[0]
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

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

sourceRank relies on an unchecked cast to MemberOrganizationWithOverrides to access .source. Since AffiliationItem is a union, this hides type issues and can mask undefined at runtime. Prefer a type guard (e.g. check 'source' in row) or narrow to member-organization rows before ranking sources.

Copilot uses AI. Check for mistakes.
Comment on lines +129 to +132
const bestRank = Math.min(...withDates.map((r) => getMemberOrganizationSourceRank(r.source)))
const topSourceGroup = withDates.filter(
(r) => getMemberOrganizationSourceRank(r.source) === bestRank,
)
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

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

This computes getMemberOrganizationSourceRank(r.source) twice per row (once for bestRank, once for filtering). Consider computing ranks once (e.g., map to {row, rank}) to avoid duplicated work and guarantee consistent ranking if the function changes.

Suggested change
const bestRank = Math.min(...withDates.map((r) => getMemberOrganizationSourceRank(r.source)))
const topSourceGroup = withDates.filter(
(r) => getMemberOrganizationSourceRank(r.source) === bestRank,
)
const rankedWithDates = withDates.map((row) => ({
row,
rank: getMemberOrganizationSourceRank(row.source),
}))
const bestRank = Math.min(...rankedWithDates.map(({ rank }) => rank))
const topSourceGroup = rankedWithDates
.filter(({ rank }) => rank === bestRank)
.map(({ row }) => row)

Copilot uses AI. Check for mistakes.
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.

2 participants