Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions project/ticket-088/intent.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

142 changes: 142 additions & 0 deletions src/graph/linker-candidates.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>, 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<string, string[]>,
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<string, string[]>,
recordId: string,
objectKeywords: Set<string> | undefined,
): void {
for (const token of [...(objectKeywords ?? [])].slice(0, 5)) {
addToBucket(buckets, `token:${token}`, recordId);
}
}

function indexTopicBuckets(
buckets: Map<string, string[]>,
recordId: string,
topics: Set<string> | undefined,
): void {
for (const topic of [...(topics ?? [])].slice(0, 12)) {
addToBucket(buckets, `topic:${topic}`, recordId);
}
}

function addToBucket(buckets: Map<string, string[]>, 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<string>,
): boolean {
if (bucketKey.startsWith('ticket:')) return false;
return configurationIds.has(leftId) && configurationIds.has(rightId);
}

function isSuppressedAstPair(
bucketKey: string,
leftId: string,
rightId: string,
astIds: Set<string>,
moduleAstIds: Set<string>,
declarationAstIds: Set<string>,
): 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<string, string[]>,
astIds: Set<string>,
moduleAstIds: Set<string>,
declarationAstIds: Set<string>,
configurationIds: Set<string>,
): Array<[string, string]> {
const output = new Map<string, [string, string]>();
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<string, RecordKeywords>,
): Array<[string, string]> {
const buckets = new Map<string, string[]>();
const astIds = new Set<string>();
const moduleAstIds = new Set<string>();
const declarationAstIds = new Set<string>();
const configurationIds = new Set<string>();
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);
}
33 changes: 33 additions & 0 deletions src/graph/linker-keywords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { keywords, topicKeywords } from '../core/text.js';
import type { IntentRecord } from '../core/types.js';

export interface RecordKeywords {
object: Set<string>;
text: Set<string>;
topics: Set<string>;
}

export function indexKeywords(records: IntentRecord[]): Map<string, RecordKeywords> {
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<string>, right: Set<string>): 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<string>, right: Set<string>): 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;
}
94 changes: 94 additions & 0 deletions src/graph/linker-relations.ts
Original file line number Diff line number Diff line change
@@ -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<SourceKind>;
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<SourceKind>(['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;
}
Loading
Loading