rmalization, conflict graph construction, ownership scoring, and automated resolution.
Phase 1: Data Normalization & Field Weight Mapping
Store consoles export raw impression data, but the data lacks structural context. The first step is to normalize keyword impressions across a trailing 30-day window and map each term to its placement field. Field weights are applied as multipliers to reflect algorithmic priority.
type StorePlatform = 'ios' | 'android';
interface FieldWeightConfig {
title: number;
subtitle: number;
keywordField: number;
longDescription: number;
developerName: number;
}
const PLATFORM_WEIGHTS: Record<StorePlatform, FieldWeightConfig> = {
ios: { title: 0.35, subtitle: 0.25, keywordField: 0.20, longDescription: 0.05, developerName: 0.05 },
android: { title: 0.40, subtitle: 0.20, keywordField: 0, longDescription: 0.15, developerName: 0.05 }
};
interface KeywordPlacement {
term: string;
field: keyof FieldWeightConfig;
impressions: number;
currentRank: number;
}
Phase 2: Conflict Graph Construction
Keywords are mapped across the entire portfolio. A directed graph is built where edges represent shared terms. High-weight field collisions (title/title, title/subtitle) are flagged as critical conflicts.
interface AppNode {
appId: string;
platform: StorePlatform;
locale: string;
placements: KeywordPlacement[];
}
interface ConflictEdge {
keyword: string;
sourceApp: string;
targetApp: string;
sourceField: keyof FieldWeightConfig;
targetField: keyof FieldWeightConfig;
collisionSeverity: 'critical' | 'moderate' | 'low';
}
function buildConflictGraph(portfolio: AppNode[]): ConflictEdge[] {
const keywordMap = new Map<string, { app: string; field: keyof FieldWeightConfig; platform: StorePlatform }[]>();
portfolio.forEach(app => {
app.placements.forEach(p => {
const normalized = p.term.toLowerCase().trim();
if (!keywordMap.has(normalized)) keywordMap.set(normalized, []);
keywordMap.get(normalized)!.push({ app: app.appId, field: p.field, platform: app.platform });
});
});
const conflicts: ConflictEdge[] = [];
keywordMap.forEach((entries, term) => {
if (entries.length < 2) return;
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const a = entries[i], b = entries[j];
const weightA = PLATFORM_WEIGHTS[a.platform][a.field];
const weightB = PLATFORM_WEIGHTS[b.platform][b.field];
const severity = (weightA + weightB) > 0.5 ? 'critical' : (weightA + weightB) > 0.3 ? 'moderate' : 'low';
conflicts.push({
keyword: term,
sourceApp: a.app,
targetApp: b.app,
sourceField: a.field,
targetField: b.field,
collisionSeverity: severity
});
}
}
});
return conflicts;
}
Phase 3: Rank-Weighted Ownership Assignment
Ownership is determined by a composite score that balances impression volume against ranking efficiency. The app with the highest score retains the keyword in its highest-weight field. Competing apps must demote the term at least one weight tier or remove it entirely.
interface OwnershipScore {
appId: string;
keyword: string;
score: number;
recommendedAction: 'retain' | 'demote' | 'remove';
}
function calculateOwnershipScore(impressions: number, rank: number): number {
const rankInverse = 1 / Math.max(rank, 1);
return impressions * rankInverse;
}
function resolveConflicts(conflicts: ConflictEdge[], portfolio: AppNode[]): OwnershipScore[] {
const resolutions: OwnershipScore[] = [];
const keywordOwners = new Map<string, { appId: string; score: number }>();
conflicts.forEach(conflict => {
const sourceApp = portfolio.find(a => a.appId === conflict.sourceApp)!;
const targetApp = portfolio.find(a => a.appId === conflict.targetApp)!;
const sourcePlacement = sourceApp.placements.find(p => p.term.toLowerCase() === conflict.keyword)!;
const targetPlacement = targetApp.placements.find(p => p.term.toLowerCase() === conflict.keyword)!;
const sourceScore = calculateOwnershipScore(sourcePlacement.impressions, sourcePlacement.currentRank);
const targetScore = calculateOwnershipScore(targetPlacement.impressions, targetPlacement.currentRank);
const winner = sourceScore >= targetScore ? sourceApp.appId : targetApp.appId;
const winnerScore = Math.max(sourceScore, targetScore);
if (!keywordOwners.has(conflict.keyword) || keywordOwners.get(conflict.keyword)!.score < winnerScore) {
keywordOwners.set(conflict.keyword, { appId: winner, score: winnerScore });
}
});
keywordOwners.forEach((owner, keyword) => {
resolutions.push({
appId: owner.appId,
keyword,
score: owner.score,
recommendedAction: 'retain'
});
});
return resolutions;
}
Architecture Decisions & Rationale
- Graph-Based Mapping Over Flat Lists: Keyword relationships are inherently many-to-many across locales and platforms. A directed graph captures collision topology, enabling batch resolution rather than sequential per-app fixes.
- Weight-Tier Demotion Logic: Store algorithms prioritize exact matches in titles. Forcing a demotion by one weight tier (e.g., title β subtitle β keyword field) preserves semantic relevance while eliminating high-weight collisions.
- Rank-Weighted Scoring: Raw impression counts are misleading. An app ranking #12 with 10,000 impressions holds less algorithmic leverage than an app ranking #3 with 8,000 impressions. The inverse-rank multiplier normalizes efficiency.
- Locale-Aware Isolation: Each locale is processed as a separate graph instance. This prevents English metadata decisions from incorrectly overriding German or Japanese collision patterns, where translation overlaps behave differently.
Pitfall Guide
1. Title-Field Collision Blindspot
Explanation: Publishers often assume that placing the same keyword in a title and a subtitle across different apps is safe. In reality, the combined weight (~35% + ~25%) still triggers significant algorithmic competition.
Fix: Enforce a strict tier boundary. If App A owns a term in the title, App B must place it in the keyword field or long description, never in the subtitle.
2. Locale Bleed Ignorance
Explanation: Metadata is frequently audited only in the primary market. Secondary locales often contain direct translation overlaps that create hidden cannibalization.
Fix: Run the conflict graph pipeline per locale. Use locale-specific impression data from console exports rather than assuming English patterns translate directly.
3. Long-Tail Overcorrection
Explanation: Resolving collisions by aggressively shifting to hyper-specific 5+ word terms can reduce search volume by 80β90%. The ranking gain becomes mathematically irrelevant if impressions drop below the algorithmic threshold.
Fix: Apply a volume floor. Only demote to long-tail variants if the trailing 30-day impression baseline remains above 15% of the original head-term volume.
4. Static Audit Decay
Explanation: Store rankings shift weekly. A one-time conflict resolution becomes obsolete within 3β4 weeks as competitor movements and algorithm updates alter impression distributions.
Fix: Schedule the pipeline to run biweekly. Automate ownership reassignment based on fresh console exports rather than manual quarterly reviews.
5. Per-App Silo Optimization
Explanation: Optimizing each listing in isolation maximizes individual relevance but fragments portfolio authority. The algorithm treats overlapping metadata as competing signals rather than complementary coverage.
Fix: Treat metadata as a shared resource pool. Assign primary ownership at the portfolio level before drafting individual listings.
Explanation: iOS and Android apply different weight distributions. Applying iOS-centric rules to Android listings (or vice versa) misallocates authority and fails to resolve collisions.
Fix: Maintain platform-specific weight configurations. The scoring engine must normalize field weights per platform before calculating collision severity.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Two apps collide in titles for a high-volume head term | Retain in highest-ranking app, demote competitor to keyword field | Preserves maximum authority while eliminating 35%+ weight collision | Low (metadata edit only) |
| Collision occurs in secondary locale with <5% portfolio traffic | Isolate locale graph, apply aggressive long-tail demotion | Minimizes operational overhead while preventing regional ranking drift | Low |
| Portfolio exceeds 15 apps with heavy keyword overlap | Implement automated graph pipeline with CI/CD scheduling | Manual resolution becomes mathematically unmanageable at scale | Medium (engineering time) |
| Long-tail fallback drops impressions below 10% threshold | Retain original term in primary app, remove from competitor entirely | Algorithmic relevance requires minimum impression velocity to sustain ranking | Low |
Configuration Template
{
"pipeline": {
"schedule": "0 2 * * 1,4",
"dataWindow": "30d",
"volumeFloor": 0.15,
"localeIsolation": true
},
"weightTiers": {
"ios": { "title": 0.35, "subtitle": 0.25, "keywordField": 0.20, "longDescription": 0.05 },
"android": { "title": 0.40, "subtitle": 0.20, "keywordField": 0.00, "longDescription": 0.15 }
},
"collisionThresholds": {
"critical": 0.50,
"moderate": 0.30,
"low": 0.15
},
"resolutionRules": {
"demoteByTier": true,
"maxDemotionDepth": 2,
"allowRemoval": true
}
}
Quick Start Guide
- Initialize the pipeline: Clone the metadata graph repository, install dependencies, and place your console export CSVs in the
/data/imports directory.
- Configure platform weights: Update
config.json to match your target stores and set the volumeFloor to 0.15 to prevent long-tail overcorrection.
- Run the conflict detector: Execute
npm run detect-conflicts -- --locale all. The engine will output a JSON conflict map and ownership assignments.
- Apply resolutions: Review the generated
resolutions.json, update App Store Connect and Play Console metadata accordingly, and verify impression distribution over the next 14 days.
- Schedule automation: Add the pipeline to your CI/CD scheduler or cron service to run biweekly, ensuring continuous alignment with shifting store algorithms.