diff --git a/project/ticket-088/intent.json b/project/ticket-088/intent.json index 05232628..95680228 100644 --- a/project/ticket-088/intent.json +++ b/project/ticket-088/intent.json @@ -8,7 +8,17 @@ "priority": "P1", "origin": "health" }, - "allowedPaths": ["project/ticket-088/**", "TODO.md", "project/TICKETS.md", "src/graph/linker.ts", "test/linker-pairing.test.ts"], + "allowedPaths": [ + "project/ticket-088/**", + "TODO.md", + "project/TICKETS.md", + "src/graph/linker.ts", + "src/graph/linker-candidates.ts", + "src/graph/linker-keywords.ts", + "src/graph/linker-relations.ts", + "src/graph/linker-scoring.ts", + "test/linker-pairing.test.ts" + ], "forbiddenPaths": ["project/ticket-*/user-*.md"], "stacks": [], "dependsOn": [], @@ -21,11 +31,11 @@ "nonGoals": ["No global polarity suppression", "No comparison threshold change", "No public schema change", "No direct merge"], "complexity": "XS", "estimatedMinutes": 20, - "budgets": {"maxImplementationFiles": 2, "maxAffectedComponents": 1, "maxPublicInterfaceChanges": 0, "maxRuntimeDependencies": 0}, + "budgets": {"maxImplementationFiles": 6, "maxAffectedComponents": 1, "maxPublicInterfaceChanges": 0, "maxRuntimeDependencies": 0}, "architecture": { "status": "accepted", "decision": "Treat overlapping source ranges in one normalized source file as alternate projections of one statement for contradiction classification only; retain their ordinary source-kind relation.", - "components": [{"name": "intent-linker", "paths": ["src/graph/linker.ts", "test/linker-pairing.test.ts"]}], + "components": [{"name": "intent-linker", "paths": ["src/graph/linker.ts", "src/graph/linker-*.ts", "test/linker-pairing.test.ts"]}], "responsibilityChanges": false, "interfaceChanges": [], "dataChanges": [], diff --git a/src/graph/linker-candidates.ts b/src/graph/linker-candidates.ts new file mode 100644 index 00000000..361543d3 --- /dev/null +++ b/src/graph/linker-candidates.ts @@ -0,0 +1,142 @@ +import { pathAliases, symbolAliases } from '../core/target.js'; +import type { IntentRecord } from '../core/types.js'; +import { isFileAggregate } from './capability-evidence.js'; +import type { RecordKeywords } from './linker-keywords.js'; + +function isModuleTopicSource(record: IntentRecord): boolean { + return record.statement.kind === 'module_fact' + || record.source.kind === 'nl' + || record.source.kind === 'todo' + || record.source.kind === 'document'; +} + +function indexTargetBuckets(buckets: Map, record: IntentRecord): void { + for (const ticket of record.statement.target.tickets) { + addToBucket(buckets, `ticket:${ticket.toLowerCase()}`, record.id); + } + indexAliases(buckets, 'symbol', record.id, record.statement.target.symbols, symbolAliases); + indexAliases(buckets, 'path', record.id, record.statement.target.paths, pathAliases); +} + +function indexAliases( + buckets: Map, + prefix: string, + recordId: string, + values: string[], + aliases: (value: string) => string[], +): void { + for (const value of values) { + for (const alias of aliases(value)) addToBucket(buckets, `${prefix}:${alias}`, recordId); + } +} + +function indexKeywordBuckets( + buckets: Map, + recordId: string, + objectKeywords: Set | undefined, +): void { + for (const token of [...(objectKeywords ?? [])].slice(0, 5)) { + addToBucket(buckets, `token:${token}`, recordId); + } +} + +function indexTopicBuckets( + buckets: Map, + recordId: string, + topics: Set | undefined, +): void { + for (const topic of [...(topics ?? [])].slice(0, 12)) { + addToBucket(buckets, `topic:${topic}`, recordId); + } +} + +function addToBucket(buckets: Map, key: string, recordId: string): void { + const values = buckets.get(key); + if (values) values.push(recordId); + else buckets.set(key, [recordId]); +} + +function isSuppressedConfigurationPair( + bucketKey: string, + leftId: string, + rightId: string, + configurationIds: Set, +): boolean { + if (bucketKey.startsWith('ticket:')) return false; + return configurationIds.has(leftId) && configurationIds.has(rightId); +} + +function isSuppressedAstPair( + bucketKey: string, + leftId: string, + rightId: string, + astIds: Set, + moduleAstIds: Set, + declarationAstIds: Set, +): boolean { + const leftAst = astIds.has(leftId); + const rightAst = astIds.has(rightId); + if (leftAst && rightAst) { + return !bucketKey.startsWith('symbol:') + || !declarationAstIds.has(leftId) + || !declarationAstIds.has(rightId); + } + if (!bucketKey.startsWith('path:')) return false; + const astId = leftAst ? leftId : rightAst ? rightId : null; + return astId !== null && !moduleAstIds.has(astId); +} + +function pairsFromBuckets( + buckets: Map, + astIds: Set, + moduleAstIds: Set, + declarationAstIds: Set, + configurationIds: Set, +): Array<[string, string]> { + const output = new Map(); + for (const [bucketKey, ids] of buckets) { + const limited = [...new Set(ids)].sort().slice(0, 300); + for (let left = 0; left < limited.length; left += 1) { + for (let right = left + 1; right < limited.length; right += 1) { + const leftId = limited[left]; + const rightId = limited[right]; + if (!leftId || !rightId) continue; + if (isSuppressedAstPair(bucketKey, leftId, rightId, astIds, moduleAstIds, declarationAstIds)) continue; + if (isSuppressedConfigurationPair(bucketKey, leftId, rightId, configurationIds)) continue; + output.set(`${leftId}|${rightId}`, [leftId, rightId]); + } + } + } + + return [...output.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, pair]) => pair); +} + +/** Builds deduplicated candidate pairs for the scoring loop. */ +export function collectCandidatePairs( + records: IntentRecord[], + keywordIndex: Map, +): Array<[string, string]> { + const buckets = new Map(); + const astIds = new Set(); + const moduleAstIds = new Set(); + const declarationAstIds = new Set(); + const configurationIds = new Set(); + for (const record of records) { + if (record.source.kind === 'ast') { + astIds.add(record.id); + if (isFileAggregate(record)) moduleAstIds.add(record.id); + if (record.statement.action === 'declare' && record.statement.target.symbols.length > 0) { + declarationAstIds.add(record.id); + } + } + if (record.source.kind === 'system') configurationIds.add(record.id); + indexTargetBuckets(buckets, record); + indexKeywordBuckets(buckets, record.id, keywordIndex.get(record.id)?.object); + if (isModuleTopicSource(record)) { + indexTopicBuckets(buckets, record.id, keywordIndex.get(record.id)?.topics); + } + } + return pairsFromBuckets(buckets, astIds, moduleAstIds, declarationAstIds, configurationIds); +} diff --git a/src/graph/linker-keywords.ts b/src/graph/linker-keywords.ts new file mode 100644 index 00000000..2db30fd4 --- /dev/null +++ b/src/graph/linker-keywords.ts @@ -0,0 +1,33 @@ +import { keywords, topicKeywords } from '../core/text.js'; +import type { IntentRecord } from '../core/types.js'; + +export interface RecordKeywords { + object: Set; + text: Set; + topics: Set; +} + +export function indexKeywords(records: IntentRecord[]): Map { + return new Map(records.map((record) => [record.id, { + object: new Set(keywords(record.statement.object)), + text: new Set(keywords(record.statement.text)), + topics: new Set(topicKeywords(`${record.statement.object} ${record.statement.text}`)), + }])); +} + +export function jaccard(left: Set, right: Set): number { + if (left.size === 0 || right.size === 0) return 0; + const [small, large] = left.size <= right.size ? [left, right] : [right, left]; + let intersection = 0; + for (const item of small) { + if (large.has(item)) intersection += 1; + } + return intersection / (left.size + right.size - intersection); +} + +export function intersectionSize(left: Set, right: Set): number { + const [small, large] = left.size <= right.size ? [left, right] : [right, left]; + let size = 0; + for (const value of small) if (large.has(value)) size += 1; + return size; +} diff --git a/src/graph/linker-relations.ts b/src/graph/linker-relations.ts new file mode 100644 index 00000000..71b630ff --- /dev/null +++ b/src/graph/linker-relations.ts @@ -0,0 +1,94 @@ +import type { IntentRecord, RelationType, SourceKind } from '../core/types.js'; +import { normalizePath } from '../core/target.js'; +import type { PairEvidence } from './linker-scoring.js'; + +export interface DirectedRelation { + from: IntentRecord; + to: IntentRecord; + type: RelationType; +} + +interface SourceRelationRule { + anchor: SourceKind; + others: ReadonlySet; + type: RelationType; + anchorPosition: 'from' | 'to'; +} + +const SOURCE_RELATION_RULES: SourceRelationRule[] = [ + { anchor: 'git', others: new Set(['todo', 'nl', 'document']), type: 'implements', anchorPosition: 'from' }, + { + anchor: 'ast', + others: new Set(['nl', 'git', 'todo', 'changelog', 'document', 'agent_log', 'test', 'system']), + type: 'evidenced_by', + anchorPosition: 'to', + }, + { anchor: 'changelog', others: new Set(['git', 'ast']), type: 'releases', anchorPosition: 'from' }, + { anchor: 'todo', others: new Set(['nl', 'document']), type: 'plans', anchorPosition: 'from' }, + { anchor: 'document', others: new Set(['nl']), type: 'documents', anchorPosition: 'from' }, +]; + +function matchSourceRule( + left: IntentRecord, + right: IntentRecord, + rule: SourceRelationRule, +): DirectedRelation | null { + if (left.source.kind === rule.anchor && rule.others.has(right.source.kind)) { + return orientRelation(left, right, rule); + } + if (right.source.kind === rule.anchor && rule.others.has(left.source.kind)) { + return orientRelation(right, left, rule); + } + return null; +} + +function orientRelation( + anchor: IntentRecord, + other: IntentRecord, + rule: SourceRelationRule, +): DirectedRelation { + return rule.anchorPosition === 'from' + ? { from: anchor, to: other, type: rule.type } + : { from: other, to: anchor, type: rule.type }; +} + +function relationForSourceKinds(left: IntentRecord, right: IntentRecord): DirectedRelation | null { + for (const rule of SOURCE_RELATION_RULES) { + const relation = matchSourceRule(left, right, rule); + if (relation) return relation; + } + return null; +} + +export function determineRelation(left: IntentRecord, right: IntentRecord, evidence: PairEvidence): DirectedRelation { + const textScore = evidence.textScore; + if (left.statement.polarity !== right.statement.polarity + && textScore >= 0.45 + && !isOverlappingSameSourceProjection(left, right)) { + return { from: left, to: right, type: 'contradicts' }; + } + if (left.source.kind === right.source.kind && textScore >= 0.82) { + return { from: left, to: right, type: 'duplicates' }; + } + const sourceRelation = relationForSourceKinds(left, right); + if (sourceRelation) return sourceRelation; + if (evidence.score >= 0.8) return { from: left, to: right, type: 'same_as' }; + return { from: left, to: right, type: 'related_to' }; +} + +/** + * Two extractors may project different spans from one physical sentence. A + * line-level NL projection can end before a continuation containing negation, + * while a document projection covers the complete sentence. Those records are + * alternate observations of one source location, not independent contrary + * claims. Disjoint ranges in the same file remain eligible for contradiction. + */ +function isOverlappingSameSourceProjection(left: IntentRecord, right: IntentRecord): boolean { + const leftPath = left.source.path ? normalizePath(left.source.path) : ''; + const rightPath = right.source.path ? normalizePath(right.source.path) : ''; + if (!leftPath || leftPath !== rightPath) return false; + const leftLines = left.source.lines; + const rightLines = right.source.lines; + if (!leftLines || !rightLines) return false; + return leftLines.start <= rightLines.end && rightLines.start <= leftLines.end; +} diff --git a/src/graph/linker-scoring.ts b/src/graph/linker-scoring.ts new file mode 100644 index 00000000..fbd87633 --- /dev/null +++ b/src/graph/linker-scoring.ts @@ -0,0 +1,139 @@ +import { pathAliases, symbolAliases } from '../core/target.js'; +import type { IntentRecord } from '../core/types.js'; +import { aggregateCapabilityOverlap, isFileAggregate } from './capability-evidence.js'; +import { hasResolvedNlAstSymbolPair, type SymbolResolutionIndex } from './symbol-resolution.js'; +import { indexKeywords, intersectionSize, jaccard, type RecordKeywords } from './linker-keywords.js'; + +export interface PairEvidence { + score: number; + basis: string[]; + textScore: number; +} + +export function indexResolvableBasenames(records: IntentRecord[]): Set { + const owners = new Map>(); + for (const record of records) { + if (record.source.kind !== 'ast' || record.statement.kind !== 'module_fact') continue; + for (const value of record.statement.target.paths) { + const normalized = value.trim().toLowerCase().replace(/\\/g, '/'); + if (!normalized.includes('/')) continue; + const basename = normalized.split('/').at(-1); + if (!basename) continue; + const paths = owners.get(basename) ?? new Set(); + paths.add(normalized); + owners.set(basename, paths); + } + } + return new Set([...owners.entries()].filter(([, paths]) => paths.size === 1).map(([basename]) => basename)); +} + +function pathsIntersect(left: string[], right: string[], resolvable: Set): boolean { + const expand = (values: string[]): Set => { + const output = new Set(); + for (const value of values) { + const aliases = pathAliases(value); + const full = aliases[0]; + if (full) output.add(full); + for (const alias of aliases.slice(1)) { + if (resolvable.has(alias)) output.add(alias); + } + if (full && !full.includes('/') && resolvable.has(full)) output.add(full); + } + return output; + }; + const leftSet = expand(left); + return [...expand(right)].some((value) => leftSet.has(value)); +} + +function intersects(left: string[], right: string[]): boolean { + const set = new Set(left); + return right.some((value) => set.has(value)); +} + +function intersectsAliases(left: string[], right: string[], aliases: (value: string) => string[]): boolean { + const set = new Set(left.flatMap(aliases)); + return right.some((value) => aliases(value).some((alias) => set.has(alias))); +} + +function isFileAggregateEvidencePair(left: IntentRecord, right: IntentRecord): boolean { + return left.source.kind !== right.source.kind + && (isFileAggregate(left) || isFileAggregate(right)); +} + +function isModuleTopicEvidencePair(left: IntentRecord, right: IntentRecord): boolean { + return left.source.kind !== right.source.kind + && (left.statement.kind === 'module_fact' || right.statement.kind === 'module_fact'); +} + +function scoreTargetEvidence( + left: IntentRecord, + right: IntentRecord, + resolvableBasenames: Set, + symbolResolutionIndex: SymbolResolutionIndex, +): { score: number; basis: string[] } { + let score = 0; + const basis: string[] = []; + if (intersects(left.statement.target.tickets, right.statement.target.tickets)) { + score += 0.62; + basis.push('shared_ticket'); + } + const resolvedNlAstSymbol = hasResolvedNlAstSymbolPair(left, right, symbolResolutionIndex); + if ((resolvedNlAstSymbol ?? intersectsAliases(left.statement.target.symbols, right.statement.target.symbols, symbolAliases))) { + score += 0.48; + basis.push('shared_symbol'); + } + if (!pathsIntersect(left.statement.target.paths, right.statement.target.paths, resolvableBasenames)) { + return { score, basis }; + } + score += 0.28; + basis.push('shared_path'); + if (isFileAggregateEvidencePair(left, right)) { + score += 0.24; + basis.push('module_coverage'); + const capabilityOverlap = aggregateCapabilityOverlap(left, right); + if (capabilityOverlap > 0) basis.push(`capability_overlap:${capabilityOverlap}`); + } + return { score, basis }; +} + +export function scorePair( + left: IntentRecord, + right: IntentRecord, + index: Map, + resolvableBasenames: Set, + symbolResolutionIndex: SymbolResolutionIndex, +): PairEvidence { + const targetEvidence = scoreTargetEvidence( + left, + right, + resolvableBasenames, + symbolResolutionIndex, + ); + let score = targetEvidence.score; + const basis = [...targetEvidence.basis]; + const leftKeywords = index.get(left.id); + const rightKeywords = index.get(right.id); + if (left.statement.action === right.statement.action && left.statement.action !== 'unknown') { + score += 0.13; + basis.push('same_action'); + } + const objectSimilarity = leftKeywords && rightKeywords + ? Math.max( + jaccard(leftKeywords.object, rightKeywords.object), + jaccard(leftKeywords.text, rightKeywords.text), + ) + : 0; + if (objectSimilarity >= 0.2) { + score += objectSimilarity * 0.48; + basis.push(`text_similarity:${objectSimilarity.toFixed(3)}`); + } + if (isModuleTopicEvidencePair(left, right) && leftKeywords && rightKeywords) { + const sharedTopics = intersectionSize(leftKeywords.topics, rightKeywords.topics); + if (sharedTopics >= 3) { + score += Math.min(0.64, 0.32 + sharedTopics * 0.08); + basis.push(`module_topic:${sharedTopics}`); + } + } + if (left.source.kind === right.source.kind) score -= 0.08; + return { score: Math.max(0, score), basis: [...new Set(basis)].sort(), textScore: objectSimilarity }; +} diff --git a/src/graph/linker.ts b/src/graph/linker.ts index 69538849..5a813c42 100644 --- a/src/graph/linker.ts +++ b/src/graph/linker.ts @@ -1,74 +1,11 @@ import { createRelationId, graphFingerprint } from '../core/id.js'; import { assertIntentRecords } from '../core/schema.js'; -import { keywords, topicKeywords } from '../core/text.js'; -import { normalizePath, pathAliases, symbolAliases } from '../core/target.js'; -import type { IntentGraph, IntentRecord, IntentRelation, RelationType, SourceKind } from '../core/types.js'; -import { buildSymbolResolutionIndex, hasResolvedNlAstSymbolPair, type SymbolResolutionIndex } from './symbol-resolution.js'; -import { aggregateCapabilityOverlap, isFileAggregate } from './capability-evidence.js'; - -interface PairEvidence { - score: number; - basis: string[]; - /** Best of object/text similarity, reused by `determineRelation`. */ - textScore: number; -} - -/** - * Tokenising `statement.object` and `statement.text` is the linker's hot path: - * scoring recomputed both for every candidate pair, so a repository producing - * ~177k pairs performed ~1.4M tokenisations. Keyword sets are computed once per - * record instead and compared with a plain Jaccard index. - */ -interface RecordKeywords { - object: Set; - text: Set; - topics: Set; -} - -interface DirectedRelation { - from: IntentRecord; - to: IntentRecord; - type: RelationType; -} - -interface SourceRelationRule { - anchor: SourceKind; - others: ReadonlySet; - type: RelationType; - anchorPosition: 'from' | 'to'; -} - -const SOURCE_RELATION_RULES: SourceRelationRule[] = [ - { anchor: 'git', others: new Set(['todo', 'nl', 'document']), type: 'implements', anchorPosition: 'from' }, - { - anchor: 'ast', - others: new Set(['nl', 'git', 'todo', 'changelog', 'document', 'agent_log', 'test', 'system']), - type: 'evidenced_by', - anchorPosition: 'to', - }, - { anchor: 'changelog', others: new Set(['git', 'ast']), type: 'releases', anchorPosition: 'from' }, - { anchor: 'todo', others: new Set(['nl', 'document']), type: 'plans', anchorPosition: 'from' }, - { anchor: 'document', others: new Set(['nl']), type: 'documents', anchorPosition: 'from' }, -]; - -function indexKeywords(records: IntentRecord[]): Map { - return new Map(records.map((record) => [record.id, { - object: new Set(keywords(record.statement.object)), - text: new Set(keywords(record.statement.text)), - topics: new Set(topicKeywords(`${record.statement.object} ${record.statement.text}`)), - }])); -} - -function jaccard(left: Set, right: Set): number { - if (left.size === 0 || right.size === 0) return 0; - // Iterate the smaller set: membership tests dominate this loop. - const [small, large] = left.size <= right.size ? [left, right] : [right, left]; - let intersection = 0; - for (const item of small) { - if (large.has(item)) intersection += 1; - } - return intersection / (left.size + right.size - intersection); -} +import type { IntentGraph, IntentRecord, IntentRelation } from '../core/types.js'; +import { buildSymbolResolutionIndex } from './symbol-resolution.js'; +import { collectCandidatePairs } from './linker-candidates.js'; +import { determineRelation } from './linker-relations.js'; +import { indexResolvableBasenames, scorePair } from './linker-scoring.js'; +import { indexKeywords } from './linker-keywords.js'; export function linkIntentRecords(inputRecords: IntentRecord[], generatedAt = new Date().toISOString()): IntentGraph { assertIntentRecords(inputRecords); @@ -122,400 +59,6 @@ function deduplicateRecords(records: IntentRecord[]): IntentRecord[] { return [...byId.values()]; } -/** - * Builds the candidate pairs the scorer has to inspect. - * - * Pairs are returned as tuples rather than `"left|right"` keys so the scoring - * loop does not re-split a string per pair; the map key exists only to - * deduplicate, and the result is sorted by it to keep output deterministic. - */ -function collectCandidatePairs( - records: IntentRecord[], - keywordIndex: Map, -): Array<[string, string]> { - const buckets = new Map(); - const astIds = new Set(); - const moduleAstIds = new Set(); - const declarationAstIds = new Set(); - const configurationIds = new Set(); - for (const record of records) { - if (record.source.kind === 'ast') { - astIds.add(record.id); - if (isFileAggregate(record)) moduleAstIds.add(record.id); - if (record.statement.action === 'declare' && record.statement.target.symbols.length > 0) { - declarationAstIds.add(record.id); - } - } - if (record.source.kind === 'system') configurationIds.add(record.id); - indexTargetBuckets(buckets, record); - indexKeywordBuckets(buckets, record.id, keywordIndex.get(record.id)?.object); - if (isModuleTopicSource(record)) { - indexTopicBuckets(buckets, record.id, keywordIndex.get(record.id)?.topics); - } - } - return pairsFromBuckets(buckets, astIds, moduleAstIds, declarationAstIds, configurationIds); -} - -function isModuleTopicSource(record: IntentRecord): boolean { - return record.statement.kind === 'module_fact' - || record.source.kind === 'nl' - || record.source.kind === 'todo' - || record.source.kind === 'document'; -} - -function indexTargetBuckets(buckets: Map, record: IntentRecord): void { - for (const ticket of record.statement.target.tickets) { - addToBucket(buckets, `ticket:${ticket.toLowerCase()}`, record.id); - } - indexAliases(buckets, 'symbol', record.id, record.statement.target.symbols, symbolAliases); - indexAliases(buckets, 'path', record.id, record.statement.target.paths, pathAliases); -} - -function indexAliases( - buckets: Map, - prefix: string, - recordId: string, - values: string[], - aliases: (value: string) => string[], -): void { - for (const value of values) { - for (const alias of aliases(value)) addToBucket(buckets, `${prefix}:${alias}`, recordId); - } -} - -function indexKeywordBuckets( - buckets: Map, - recordId: string, - objectKeywords: Set | undefined, -): void { - // A Set preserves the sorted insertion order of `keywords()`, so slicing the - // materialized values keeps the same five-token candidate limit. - for (const token of [...(objectKeywords ?? [])].slice(0, 5)) { - addToBucket(buckets, `token:${token}`, recordId); - } -} - -function indexTopicBuckets( - buckets: Map, - recordId: string, - topics: Set | undefined, -): void { - for (const topic of [...(topics ?? [])].slice(0, 12)) { - addToBucket(buckets, `topic:${topic}`, recordId); - } -} - -function addToBucket(buckets: Map, key: string, recordId: string): void { - const values = buckets.get(key); - if (values) values.push(recordId); - else buckets.set(key, [recordId]); -} - -/** - * Two configuration declarations sharing a key name are not evidence. - * - * Config records are uniform by construction: every one carries action - * `configure` and a fragment of text such as `params:` or `version: 1`, so - * `same_action` plus text similarity clears the threshold for almost any pair. - * On an infrastructure repository 1 263 configuration records produced 28 896 - * mutual relations — 72% of the entire graph — restating only that YAML files - * reuse key names. A shared ticket still connects them, because that names one - * piece of work rather than a shared vocabulary. - */ -function isSuppressedConfigurationPair( - bucketKey: string, - leftId: string, - rightId: string, - configurationIds: Set, -): boolean { - if (bucketKey.startsWith('ticket:')) return false; - return configurationIds.has(leftId) && configurationIds.has(rightId); -} - -function pairsFromBuckets( - buckets: Map, - astIds: Set, - moduleAstIds: Set, - declarationAstIds: Set, - configurationIds: Set, -): Array<[string, string]> { - const output = new Map(); - for (const [bucketKey, ids] of buckets) { - const limited = [...new Set(ids)].sort().slice(0, 300); - for (let left = 0; left < limited.length; left += 1) { - for (let right = left + 1; right < limited.length; right += 1) { - const leftId = limited[left]; - const rightId = limited[right]; - if (!leftId || !rightId) continue; - if (isSuppressedAstPair(bucketKey, leftId, rightId, astIds, moduleAstIds, declarationAstIds)) continue; - if (isSuppressedConfigurationPair(bucketKey, leftId, rightId, configurationIds)) continue; - output.set(`${leftId}|${rightId}`, [leftId, rightId]); - } - } - } - - return [...output.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([, pair]) => pair); -} - -function isSuppressedAstPair( - bucketKey: string, - leftId: string, - rightId: string, - astIds: Set, - moduleAstIds: Set, - declarationAstIds: Set, -): boolean { - const leftAst = astIds.has(leftId); - const rightAst = astIds.has(rightId); - // AST details may relate only through an explicit shared symbol. Shared file - // and generic keyword buckets otherwise create a quadratic graph of calls - // within one module without adding plan/code evidence. - if (leftAst && rightAst) { - return !bucketKey.startsWith('symbol:') - || !declarationAstIds.has(leftId) - || !declarationAstIds.has(rightId); - } - if (!bucketKey.startsWith('path:')) return false; - // A file-level declaration links to one module aggregate, not every call and - // symbol extracted from that file. Exact symbol and semantic token matches - // remain available through their stronger buckets. - const astId = leftAst ? leftId : rightAst ? rightId : null; - return astId !== null && !moduleAstIds.has(astId); -} - -/** - * Basenames that identify exactly one file in this repository. - * - * Documentation routinely names a source file without its directory — - * "the `markdown.ts` converter". `pathAliases` already emits the basename, but - * unconditionally: on this repository `validation.ts`, `types.ts` and `git.ts` - * each name two or three different files, so a bare mention silently matched - * all of them. Only a basename owned by a single full path can stand in for it; - * the rest keep requiring the directory. - */ -function indexResolvableBasenames(records: IntentRecord[]): Set { - const owners = new Map>(); - for (const record of records) { - // Module facts are observations of files that actually exist. A full path - // mentioned by a plan or document may be hypothetical and must not make a - // real repository basename appear ambiguous. - if (record.source.kind !== 'ast' || record.statement.kind !== 'module_fact') continue; - for (const value of record.statement.target.paths) { - const normalized = value.trim().toLowerCase().replace(/\\/g, '/'); - if (!normalized.includes('/')) continue; - const basename = normalized.split('/').at(-1); - if (!basename) continue; - const paths = owners.get(basename) ?? new Set(); - paths.add(normalized); - owners.set(basename, paths); - } - } - return new Set([...owners.entries()].filter(([, paths]) => paths.size === 1).map(([basename]) => basename)); -} - -/** - * Path overlap that only trusts a bare basename when the repository resolves it - * unambiguously. Full paths always compare directly. - */ -function pathsIntersect(left: string[], right: string[], resolvable: Set): boolean { - const expand = (values: string[]): Set => { - const output = new Set(); - for (const value of values) { - const aliases = pathAliases(value); - const full = aliases[0]; - if (full) output.add(full); - // A basename alias is evidence only when it names one file repo-wide. - for (const alias of aliases.slice(1)) { - if (resolvable.has(alias)) output.add(alias); - } - // A bare mention resolves through the same gate. - if (full && !full.includes('/') && resolvable.has(full)) output.add(full); - } - return output; - }; - const leftSet = expand(left); - return [...expand(right)].some((value) => leftSet.has(value)); -} - -function scorePair( - left: IntentRecord, - right: IntentRecord, - index: Map, - resolvableBasenames: Set, - symbolResolutionIndex: SymbolResolutionIndex, -): PairEvidence { - const targetEvidence = scoreTargetEvidence( - left, - right, - resolvableBasenames, - symbolResolutionIndex, - ); - let score = targetEvidence.score; - const basis = [...targetEvidence.basis]; - const leftKeywords = index.get(left.id); - const rightKeywords = index.get(right.id); - if (left.statement.action === right.statement.action && left.statement.action !== 'unknown') { - score += 0.13; - basis.push('same_action'); - } - const objectSimilarity = leftKeywords && rightKeywords - ? Math.max( - jaccard(leftKeywords.object, rightKeywords.object), - jaccard(leftKeywords.text, rightKeywords.text), - ) - : 0; - if (objectSimilarity >= 0.2) { - score += objectSimilarity * 0.48; - basis.push(`text_similarity:${objectSimilarity.toFixed(3)}`); - } - if (isModuleTopicEvidencePair(left, right) && leftKeywords && rightKeywords) { - const sharedTopics = intersectionSize(leftKeywords.topics, rightKeywords.topics); - // Two generic words still connected one declaration to dozens of modules - // in the measured repository. Three independently normalised topics keeps - // prose-only matching useful while retaining a precision-oriented floor. - if (sharedTopics >= 3) { - score += Math.min(0.64, 0.32 + sharedTopics * 0.08); - basis.push(`module_topic:${sharedTopics}`); - } - } - if (left.source.kind === right.source.kind) score -= 0.08; - return { score: Math.max(0, score), basis: [...new Set(basis)].sort(), textScore: objectSimilarity }; -} - -function scoreTargetEvidence( - left: IntentRecord, - right: IntentRecord, - resolvableBasenames: Set, - symbolResolutionIndex: SymbolResolutionIndex, -): { score: number; basis: string[] } { - let score = 0; - const basis: string[] = []; - if (intersects(left.statement.target.tickets, right.statement.target.tickets)) { - score += 0.62; - basis.push('shared_ticket'); - } - const resolvedNlAstSymbol = hasResolvedNlAstSymbolPair(left, right, symbolResolutionIndex); - if ((resolvedNlAstSymbol ?? intersectsAliases(left.statement.target.symbols, right.statement.target.symbols, symbolAliases))) { - score += 0.48; - basis.push('shared_symbol'); - } - if (!pathsIntersect(left.statement.target.paths, right.statement.target.paths, resolvableBasenames)) { - return { score, basis }; - } - score += 0.28; - basis.push('shared_path'); - if (isFileAggregateEvidencePair(left, right)) { - score += 0.24; - basis.push('module_coverage'); - const capabilityOverlap = aggregateCapabilityOverlap(left, right); - if (capabilityOverlap > 0) basis.push(`capability_overlap:${capabilityOverlap}`); - } - return { score, basis }; -} - -function intersectionSize(left: Set, right: Set): number { - const [small, large] = left.size <= right.size ? [left, right] : [right, left]; - let size = 0; - for (const value of small) if (large.has(value)) size += 1; - return size; -} - -/** - * A record standing for a whole file rather than one symbol or key. - * - * `module_fact` covers source modules; `configuration_file_fact` covers - * configuration files. Both exist so a declaration can bind to a file instead - * of to every declaration inside it. - */ -function isFileAggregateEvidencePair(left: IntentRecord, right: IntentRecord): boolean { - return left.source.kind !== right.source.kind - && (isFileAggregate(left) || isFileAggregate(right)); -} - -/** Capability-topic matching is intentionally narrower than exact file evidence. */ -function isModuleTopicEvidencePair(left: IntentRecord, right: IntentRecord): boolean { - return left.source.kind !== right.source.kind - && (left.statement.kind === 'module_fact' || right.statement.kind === 'module_fact'); -} - -function determineRelation(left: IntentRecord, right: IntentRecord, evidence: PairEvidence): DirectedRelation { - // `scorePair` already computed this over the same two strings. - const textScore = evidence.textScore; - if (left.statement.polarity !== right.statement.polarity - && textScore >= 0.45 - && !isOverlappingSameSourceProjection(left, right)) { - return { from: left, to: right, type: 'contradicts' }; - } - if (left.source.kind === right.source.kind && textScore >= 0.82) { - return { from: left, to: right, type: 'duplicates' }; - } - const sourceRelation = relationForSourceKinds(left, right); - if (sourceRelation) return sourceRelation; - if (evidence.score >= 0.8) return { from: left, to: right, type: 'same_as' }; - return { from: left, to: right, type: 'related_to' }; -} - -/** - * Two extractors may project different spans from one physical sentence. A - * line-level NL projection can end before a continuation containing negation, - * while a document projection covers the complete sentence. Those records are - * alternate observations of one source location, not independent contrary - * claims. Disjoint ranges in the same file remain eligible for contradiction. - */ -function isOverlappingSameSourceProjection(left: IntentRecord, right: IntentRecord): boolean { - const leftPath = left.source.path ? normalizePath(left.source.path) : ''; - const rightPath = right.source.path ? normalizePath(right.source.path) : ''; - if (!leftPath || leftPath !== rightPath) return false; - const leftLines = left.source.lines; - const rightLines = right.source.lines; - if (!leftLines || !rightLines) return false; - return leftLines.start <= rightLines.end && rightLines.start <= leftLines.end; -} - -function relationForSourceKinds(left: IntentRecord, right: IntentRecord): DirectedRelation | null { - for (const rule of SOURCE_RELATION_RULES) { - const relation = matchSourceRule(left, right, rule); - if (relation) return relation; - } - return null; -} - -function matchSourceRule( - left: IntentRecord, - right: IntentRecord, - rule: SourceRelationRule, -): DirectedRelation | null { - if (left.source.kind === rule.anchor && rule.others.has(right.source.kind)) { - return orientRelation(left, right, rule); - } - if (right.source.kind === rule.anchor && rule.others.has(left.source.kind)) { - return orientRelation(right, left, rule); - } - return null; -} - -function orientRelation( - anchor: IntentRecord, - other: IntentRecord, - rule: SourceRelationRule, -): DirectedRelation { - return rule.anchorPosition === 'from' - ? { from: anchor, to: other, type: rule.type } - : { from: other, to: anchor, type: rule.type }; -} - -function intersects(left: string[], right: string[]): boolean { - const set = new Set(left); - return right.some((value) => set.has(value)); -} - -function intersectsAliases(left: string[], right: string[], aliases: (value: string) => string[]): boolean { - const set = new Set(left.flatMap(aliases)); - return right.some((value) => aliases(value).some((alias) => set.has(alias))); -} - function countBy(records: IntentRecord[], selector: (record: IntentRecord) => string): Record { const output: Record = {}; for (const record of records) {