diff --git a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx index bd221cd72ed..ad1e4e8d87e 100644 --- a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx @@ -191,24 +191,29 @@ export function RulerBasedGroupRules({ return ( <> - {pageItems.map((rulerRule) => { + {pageItems.map((rulerRule, index) => { + // If rules are indistinguishable by name, labels, annotations, and query, we need to use the index to disambiguate const promRule = matches.get(rulerRule); - return promRule ? ( - - } - showLocation={false} - /> - ) : ( + if (promRule) { + return ( + + } + showLocation={false} + /> + ); + } + + return ( ); })} - {promOnlyRules.map((rule) => ( - - ))} + {promOnlyRules.map((rule, index) => { + return ( + + ); + })} {hasMore && (
  • diff --git a/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx b/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx index ac54bc375b3..5e0cf04d982 100644 --- a/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx @@ -1,8 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { memo, useMemo } from 'react'; -import { DataSourceRuleGroupIdentifier, Rule } from 'app/types/unified-alerting'; - import { alertRuleApi } from '../api/alertRuleApi'; import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; import { isCloudRulerGroup } from '../utils/rules'; @@ -10,20 +8,18 @@ import { isCloudRulerGroup } from '../utils/rules'; import { DataSourceRuleListItem } from './DataSourceRuleListItem'; import { RuleActionsButtons } from './components/RuleActionsButtons.V2'; import { RuleActionsSkeleton } from './components/RuleActionsSkeleton'; +import { PromRuleWithOrigin } from './hooks/useFilteredRulesIterator'; import { getMatchingRulerRule } from './ruleMatching'; const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; const { useGetRuleGroupForNamespaceQuery } = alertRuleApi; interface DataSourceRuleLoaderProps { - rule: Rule; - groupIdentifier: DataSourceRuleGroupIdentifier; + ruleWithOrigin: PromRuleWithOrigin; } -export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({ - rule, - groupIdentifier, -}: DataSourceRuleLoaderProps) { +export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({ ruleWithOrigin }: DataSourceRuleLoaderProps) { + const { rule, groupIdentifier } = ruleWithOrigin; const { rulesSource, namespace, groupName } = groupIdentifier; const { data: dsFeatures } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid }); @@ -43,8 +39,8 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({ return; } - return getMatchingRulerRule(rulerRuleGroup, rule); - }, [rulerRuleGroup, rule]); + return getMatchingRulerRule(rulerRuleGroup, ruleWithOrigin); + }, [rulerRuleGroup, ruleWithOrigin]); // 1. get the rule from the ruler API with "ruleWithLocation" // 1.1 skip this if this datasource does not have a ruler diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx index 08a8ecbce43..5e1a651cdaf 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -162,7 +162,7 @@ function FilterViewResults({ filterState }: FilterViewProps) { /> ); case 'datasource': - return ; + return ; default: return ( :" (e.g., "0:3", "1:5") + * + * This is used as a tiebreaker when multiple identical rules exist in a group and need to be + * matched against their counterparts in another rule source (e.g., matching Prometheus rules + * to Ruler API rules). The hash ensures that identical rules are matched by their position + * only when both groups have the same structure (same number of rules). + * + * @example + * // Two identical alerts in different positions + * Rule at position 0 in a 3-rule group: rulePositionHash = "0:3" + * Rule at position 1 in a 3-rule group: rulePositionHash = "1:3" + * // These won't match rules in a 2-rule group (e.g., "0:2") even if identical + */ + rulePositionHash: RulePositionHash; } interface GetIteratorResult { @@ -99,9 +116,9 @@ export function useFilteredRulesIteratorProvider() { concatMap((groups) => groups .filter((group) => groupFilter(group, normalizedFilterState)) - .flatMap((group) => group.rules.map((rule) => ({ group, rule }))) + .flatMap((group) => group.rules.map((rule, index) => ({ group, rule, index }))) .filter(({ rule }) => ruleFilter(rule, normalizedFilterState)) - .map(({ group, rule }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule)) + .map(({ group, rule, index }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule, index)) ), catchError(() => empty()) ); @@ -166,7 +183,8 @@ function getRulesSourcesFromFilter(filter: RulesFilter): DataSourceRulesSourceId function mapRuleToRuleWithOrigin( rulesSource: DataSourceRulesSourceIdentifier, group: PromRuleGroupDTO, - rule: PromRuleDTO + rule: PromRuleDTO, + ruleIndex: number ): PromRuleWithOrigin { return { rule, @@ -177,6 +195,7 @@ function mapRuleToRuleWithOrigin( groupOrigin: 'datasource', }, origin: 'datasource', + rulePositionHash: createRulePositionHash(ruleIndex, group.rules.length), }; } diff --git a/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts b/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts index ee542bac1bf..87ee3e35f95 100644 --- a/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts +++ b/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts @@ -1,7 +1,38 @@ +import { PromRuleDTO, RulerCloudRuleDTO } from 'app/types/unified-alerting-dto'; + import { mockPromRecordingRule } from '../mocks'; import { alertingFactory } from '../mocks/server/db'; +import { PromRuleWithOrigin } from './hooks/useFilteredRulesIterator'; import { getMatchingPromRule, getMatchingRulerRule, matchRulesGroup } from './ruleMatching'; +import { RulePositionHash, createRulePositionHash } from './rulePositionHash'; + +// Helper to create PromRuleWithOrigin mock +function createPromRuleWithOrigin(rule: PromRuleDTO, ruleIndex: number, totalRules: number): PromRuleWithOrigin { + return { + rule, + groupIdentifier: { + rulesSource: { uid: 'test-ds', name: 'test-datasource', ruleSourceType: 'datasource' }, + namespace: { name: 'test-namespace' }, + groupName: 'test-group', + groupOrigin: 'datasource', + }, + origin: 'datasource', + rulePositionHash: createRulePositionHash(ruleIndex, totalRules), + }; +} + +// Helper to create RulerRuleWithRulePosition mock +function createRulerRuleWithPosition( + rule: RulerCloudRuleDTO, + ruleIndex: number, + totalRules: number +): RulerCloudRuleDTO & { rulePositionHash: RulePositionHash } { + return { + ...rule, + rulePositionHash: createRulePositionHash(ruleIndex, totalRules), + }; +} describe('getMatchingRulerRule', () => { it('should match rule by unique name', () => { @@ -11,8 +42,9 @@ describe('getMatchingRulerRule', () => { // Create a matching prom rule with same name const promRule = alertingFactory.prometheus.rule.build({ name: 'test-rule' }); + const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 1); - const match = getMatchingRulerRule(rulerGroup, promRule); + const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin); expect(match).toBe(rulerRule); }); @@ -30,8 +62,9 @@ describe('getMatchingRulerRule', () => { labels: { severity: 'warning' }, annotations: { summary: 'test' }, }); + const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 1); - const match = getMatchingRulerRule(rulerGroup, promRule); + const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin); expect(match).toBeUndefined(); }); @@ -55,8 +88,9 @@ describe('getMatchingRulerRule', () => { labels: { severity: 'warning' }, annotations: { summary: 'test' }, }); + const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2); - const match = getMatchingRulerRule(rulerGroup, promRule); + const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin); expect(match).toBe(rulerRule1); }); @@ -83,8 +117,9 @@ describe('getMatchingRulerRule', () => { annotations: { summary: 'test' }, query: 'up == 1', }); + const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2); - const match = getMatchingRulerRule(rulerGroup, promRule); + const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin); expect(match).toBe(rulerRule1); }); @@ -111,8 +146,9 @@ describe('getMatchingRulerRule', () => { annotations: { summary: 'other' }, query: 'up == 2', }); + const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2); - const match = getMatchingRulerRule(rulerGroup, promRule); + const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin); expect(match).toBeUndefined(); }); @@ -141,10 +177,120 @@ max by (service) (up{env="staging"}) * 0.8`, labels: {}, query: `max by (service) (up{env="production"}) or max by (service) (up{env="staging"}) * 0.8`, }); + const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2); - const match = getMatchingRulerRule(rulerGroup, promRule); + const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin); expect(match).toBe(rulerRuleWithComments); }); + + it('should match prometheus rule against one of two identical ruler rules', () => { + // Create two identical ruler alerting rules + const rulerRule1 = alertingFactory.ruler.alertingRule.build({ + alert: 'KubeJobFailed', + expr: `kube_job_failed{job!=\"\"} > 0\n`, + labels: { severity: 'warning' }, + annotations: { summary: 'Job failed to complete.' }, + }); + const rulerRule2 = alertingFactory.ruler.alertingRule.build({ + alert: 'KubeJobFailed', + expr: `kube_job_failed{job!=\"\"} > 0\n`, + labels: { severity: 'warning' }, + annotations: { summary: 'Job failed to complete.' }, + }); + const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] }); + + // Create corresponding prometheus rule at position 0 (should match rulerRule1) + const promRule = alertingFactory.prometheus.rule.build({ + name: 'KubeJobFailed', + query: `kube_job_failed{job!=\"\"} > 0`, + labels: { severity: 'warning' }, + annotations: { summary: 'Job failed to complete.' }, + }); + const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2); + + const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin); + // Should match the first ruler rule because position hash matches (0:2) + expect(match).toBeDefined(); + expect(match).toEqual(rulerRule1); + }); + + it('should match second identical rule when position hash indicates index 1', () => { + // Create three identical ruler alerting rules to test matching at different positions + const rulerRule1 = alertingFactory.ruler.alertingRule.build({ + alert: 'HighMemoryUsage', + expr: `memory_usage > 90`, + labels: { severity: 'critical' }, + annotations: { summary: 'High memory' }, + }); + const rulerRule2 = alertingFactory.ruler.alertingRule.build({ + alert: 'HighMemoryUsage', + expr: `memory_usage > 90`, + labels: { severity: 'critical' }, + annotations: { summary: 'High memory' }, + }); + const rulerRule3 = alertingFactory.ruler.alertingRule.build({ + alert: 'HighMemoryUsage', + expr: `memory_usage > 90`, + labels: { severity: 'critical' }, + annotations: { summary: 'High memory' }, + }); + const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2, rulerRule3] }); + + // Create prometheus rule at position 1 (should match rulerRule2, the middle one) + const promRule = alertingFactory.prometheus.rule.build({ + name: 'HighMemoryUsage', + query: `memory_usage > 90`, + labels: { severity: 'critical' }, + annotations: { summary: 'High memory' }, + }); + const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 1, 3); + + const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin); + // Should match the second ruler rule because position hash is "1:3" + expect(match).toBeDefined(); + expect(match).toBe(rulerRule2); + // Verify it's NOT matching the first or third rule + expect(match).not.toBe(rulerRule1); + expect(match).not.toBe(rulerRule3); + }); + + it('should NOT match when group sizes differ even with identical rules', () => { + // Create a ruler group with 3 identical rules + const rulerRule1 = alertingFactory.ruler.alertingRule.build({ + alert: 'DiskSpaceLow', + expr: `disk_free < 10`, + labels: { severity: 'warning' }, + annotations: { summary: 'Low disk space' }, + }); + const rulerRule2 = alertingFactory.ruler.alertingRule.build({ + alert: 'DiskSpaceLow', + expr: `disk_free < 10`, + labels: { severity: 'warning' }, + annotations: { summary: 'Low disk space' }, + }); + const rulerRule3 = alertingFactory.ruler.alertingRule.build({ + alert: 'DiskSpaceLow', + expr: `disk_free < 10`, + labels: { severity: 'warning' }, + annotations: { summary: 'Low disk space' }, + }); + const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2, rulerRule3] }); + + // Create prometheus rule with position hash indicating 2 rules total (but ruler has 3) + const promRule = alertingFactory.prometheus.rule.build({ + name: 'DiskSpaceLow', + query: `disk_free < 10`, + labels: { severity: 'warning' }, + annotations: { summary: 'Low disk space' }, + }); + // Position hash "0:2" indicates prom group has 2 rules, but ruler group has 3 + const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2); + + const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin); + // Should NOT match because position hash "0:2" doesn't match any ruler rule + // (ruler rules would have hashes "0:3", "1:3", "2:3") + expect(match).toBeUndefined(); + }); }); describe('getMatchingPromRule', () => { @@ -155,8 +301,9 @@ describe('getMatchingPromRule', () => { // Create a matching ruler rule with same name const rulerRule = alertingFactory.ruler.alertingRule.build({ alert: 'test-rule' }); + const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 1); - const match = getMatchingPromRule(promGroup, rulerRule); + const match = getMatchingPromRule(promGroup, rulerRuleWithPosition); expect(match).toBe(promRule); }); @@ -174,8 +321,9 @@ describe('getMatchingPromRule', () => { labels: { severity: 'warning' }, annotations: { summary: 'test' }, }); + const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 1); - const match = getMatchingPromRule(promGroup, rulerRule); + const match = getMatchingPromRule(promGroup, rulerRuleWithPosition); expect(match).toBeUndefined(); }); @@ -199,8 +347,9 @@ describe('getMatchingPromRule', () => { labels: { severity: 'warning' }, annotations: { summary: 'test' }, }); + const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2); - const match = getMatchingPromRule(promGroup, rulerRule); + const match = getMatchingPromRule(promGroup, rulerRuleWithPosition); expect(match).toBe(promRule1); }); @@ -227,8 +376,9 @@ describe('getMatchingPromRule', () => { annotations: { summary: 'test' }, expr: 'up == 1', }); + const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2); - const match = getMatchingPromRule(promGroup, rulerRule); + const match = getMatchingPromRule(promGroup, rulerRuleWithPosition); expect(match).toBe(promRule1); }); @@ -255,8 +405,9 @@ describe('getMatchingPromRule', () => { annotations: { summary: 'other' }, expr: 'up == 2', }); + const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2); - const match = getMatchingPromRule(promGroup, rulerRule); + const match = getMatchingPromRule(promGroup, rulerRuleWithPosition); expect(match).toBeUndefined(); }); @@ -285,10 +436,42 @@ or # Fall back to staging for missing production metrics max by (service) (up{env="staging"}) * 0.8`, }); + const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2); - const match = getMatchingPromRule(promGroup, rulerRule); + const match = getMatchingPromRule(promGroup, rulerRuleWithPosition); expect(match).toBe(promRuleWithoutComments); }); + + it('should match ruler rule against one of two identical prometheus rules', () => { + // Create two identical prometheus alerting rules + const promRule1 = alertingFactory.prometheus.rule.build({ + name: 'KubeJobFailed', + query: `kube_job_failed{job!=\"\"} > 0`, + labels: { severity: 'warning' }, + annotations: { summary: 'Job failed to complete.' }, + }); + const promRule2 = alertingFactory.prometheus.rule.build({ + name: 'KubeJobFailed', + query: `kube_job_failed{job!=\"\"} > 0`, + labels: { severity: 'warning' }, + annotations: { summary: 'Job failed to complete.' }, + }); + const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] }); + + // Create corresponding ruler rule at position 0 (should match promRule1) + const rulerRule = alertingFactory.ruler.alertingRule.build({ + alert: 'KubeJobFailed', + expr: `kube_job_failed{job!=\"\"} > 0\n`, + labels: { severity: 'warning' }, + annotations: { summary: 'Job failed to complete.' }, + }); + const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2); + + const match = getMatchingPromRule(promGroup, rulerRuleWithPosition); + // Should match the first prometheus rule because position hash matches (0:2) + expect(match).toBeDefined(); + expect(match).toEqual(promRule1); + }); }); describe('matchRulesGroup', () => { diff --git a/public/app/features/alerting/unified/rule-list/ruleMatching.ts b/public/app/features/alerting/unified/rule-list/ruleMatching.ts index 513a43be6f8..ad188ac2526 100644 --- a/public/app/features/alerting/unified/rule-list/ruleMatching.ts +++ b/public/app/features/alerting/unified/rule-list/ruleMatching.ts @@ -1,4 +1,3 @@ -import { Rule } from 'app/types/unified-alerting'; import { PromRuleDTO, PromRuleGroupDTO, @@ -10,7 +9,15 @@ import { import { getPromRuleFingerprint, getRulerRuleFingerprint } from '../utils/rule-id'; import { getRuleName } from '../utils/rules'; -export function getMatchingRulerRule(rulerRuleGroup: RulerRuleGroupDTO, rule: Rule) { +import { PromRuleWithOrigin } from './hooks/useFilteredRulesIterator'; +import { RulePositionHash, createRulePositionHash } from './rulePositionHash'; + +export function getMatchingRulerRule( + rulerRuleGroup: RulerRuleGroupDTO, + promRuleWithOrigin: PromRuleWithOrigin +) { + const { rule, rulePositionHash } = promRuleWithOrigin; + // If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule const rulesByName = rulerRuleGroup.rules.filter((r) => getRuleName(r) === rule.name); if (rulesByName.length === 1) { @@ -35,10 +42,47 @@ export function getMatchingRulerRule(rulerRuleGroup: RulerRuleGroupDTO 1 && rulePositionHash) { + for (const candidateRule of rulesByLabelsAndAnnotationsAndQuery) { + const rulerRuleIndex = rulerRuleGroup.rules.indexOf(candidateRule); + const rulerPositionHash = createRulePositionHash(rulerRuleIndex, rulerRuleGroup.rules.length); + + // Match if position hashes are identical + if (rulerPositionHash === rulePositionHash) { + return candidateRule; + } + } + } + return undefined; } -export function getMatchingPromRule(promRuleGroup: PromRuleGroupDTO, rule: RulerCloudRuleDTO) { +type RulerRuleWithRulePosition = RulerCloudRuleDTO & { + /** + * Position hash encoding both the rule's index and the total number of rules in the group. + * Format: ":" (e.g., "0:3", "1:5") + * + * This is used to disambiguate between identical Ruler rules when matching them against + * Prometheus rules. The hash ensures rules are matched by position only when both groups + * have the same structure (same number of rules). + * + * @example + * // Matching identical rules by position + * Ruler rule at position 0 in a 2-rule group: rulePositionHash = "0:2" + * Prometheus rule at position 0 in a 2-rule group: rulePositionHash = "0:2" → Match! + * Prometheus rule at position 0 in a 3-rule group: rulePositionHash = "0:3" → No match + */ + rulePositionHash: RulePositionHash; +}; + +export function getMatchingPromRule( + promRuleGroup: PromRuleGroupDTO, + rulerRuleWithPosition: RulerRuleWithRulePosition +) { + const { rulePositionHash, ...rule } = rulerRuleWithPosition; + // If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule const rulesByName = promRuleGroup.rules.filter((r) => r.name === getRuleName(rule)); if (rulesByName.length === 1) { @@ -63,6 +107,20 @@ export function getMatchingPromRule(promRuleGroup: PromRuleGroupDTO return rulesByLabelsAndAnnotationsAndQuery[0]; } + // If there are still multiple identical matches, use position hash to disambiguate + // This only works if both groups have the same structure (same number of rules in the same order) + if (rulesByLabelsAndAnnotationsAndQuery.length > 1 && rulePositionHash) { + for (const candidateRule of rulesByLabelsAndAnnotationsAndQuery) { + const promRuleIndex = promRuleGroup.rules.indexOf(candidateRule); + const promPositionHash = createRulePositionHash(promRuleIndex, promRuleGroup.rules.length); + + // Match if position hashes are identical + if (promPositionHash === rulePositionHash) { + return candidateRule; + } + } + } + return undefined; } @@ -76,10 +134,16 @@ export function matchRulesGroup( promGroup: PromRuleGroupDTO ): GroupMatchingResult { const matchingResult = rulerGroup.rules.reduce( - (acc, rulerRule) => { + (acc, rulerRule, index) => { const { matches, unmatchedPromRules } = acc; - const promRule = getMatchingPromRule(promGroup, rulerRule); + // Create ruler rule with position hash + const rulerRuleWithPosition: RulerRuleWithRulePosition = { + ...rulerRule, + rulePositionHash: createRulePositionHash(index, rulerGroup.rules.length), + }; + + const promRule = getMatchingPromRule(promGroup, rulerRuleWithPosition); if (promRule) { matches.set(rulerRule, promRule); unmatchedPromRules.delete(promRule); diff --git a/public/app/features/alerting/unified/rule-list/rulePositionHash.ts b/public/app/features/alerting/unified/rule-list/rulePositionHash.ts new file mode 100644 index 00000000000..462cc415f31 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/rulePositionHash.ts @@ -0,0 +1,20 @@ +/** + * A branded type representing a rule's position within a group. + * Format: ":" (e.g., "0:3", "1:5") + * + * This is used to disambiguate between identical rules when matching them across + * different rule sources (e.g., matching Prometheus rules to Ruler API rules). + * The hash ensures rules are matched by position only when both groups have the + * same structure (same number of rules). + * + * @example + * // Matching identical rules by position + * Rule at position 0 in a 2-rule group: "0:2" + * Rule at position 1 in a 3-rule group: "1:3" + * // These won't match rules in differently-sized groups even if identical + */ +export type RulePositionHash = `${number}:${number}`; + +export function createRulePositionHash(ruleIndex: number, totalRules: number): RulePositionHash { + return `${ruleIndex}:${totalRules}`; +}