fix(ingestion): calculate confidence per resolution tier for heritage/MRO edges (#412)

Instead of hardcoding confidence: 1.0, compute it at ingestion time using
the same resolution tier system that CALLS edges already use.

Heritage edges (EXTENDS, IMPLEMENTS):
- resolveHeritageId now returns { id, confidence } using TIER_CONFIDENCE
- Same-file → 0.95, import-scoped → 0.9, global → 0.5
- Edge confidence = geometric mean of source and target confidence
  (principled for partially-correlated cross-scope estimates, per
  Dillig et al. POPL 2011 and Dempster-Shafer theory)

MRO edges (OVERRIDES):
- MRO-ordered → 0.9, class method wins → 0.95
- Single interface → 0.85, ambiguous/unresolved → 0.5

IMPORTS and CONTAINS intentionally keep 1.0 (deterministic).

Closes #412
This commit is contained in:
Shunsuke Hayashi 2026-03-22 13:21:49 +09:00
parent b75e380bd0
commit d206bf6772
5 changed files with 251 additions and 53 deletions

View file

@ -25,6 +25,7 @@ import { SupportedLanguages } from '../../config/supported-languages.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedHeritage } from './workers/parse-worker.js';
import type { ResolutionContext } from './resolution-context.js';
import { TIER_CONFIDENCE } from './resolution-context.js';
/** C#/Java convention: interfaces start with I followed by an uppercase letter */
const INTERFACE_NAME_RE = /^I[A-Z]/;
@ -66,22 +67,28 @@ const resolveExtendsType = (
* Resolve a symbol ID for heritage, with fallback to generated ID.
* Uses ctx.resolve() → pick first candidate's nodeId → generate synthetic ID.
*/
interface ResolvedHeritage {
readonly id: string;
readonly confidence: number;
}
const resolveHeritageId = (
name: string,
filePath: string,
ctx: ResolutionContext,
fallbackLabel: string,
fallbackKey?: string,
): string => {
): ResolvedHeritage => {
const resolved = ctx.resolve(name, filePath);
if (resolved && resolved.candidates.length > 0) {
// For global with multiple candidates, refuse (a wrong edge is worse than no edge)
if (resolved.tier === 'global' && resolved.candidates.length > 1) {
return generateId(fallbackLabel, fallbackKey ?? name);
return { id: generateId(fallbackLabel, fallbackKey ?? name), confidence: TIER_CONFIDENCE['global'] };
}
return resolved.candidates[0].nodeId;
return { id: resolved.candidates[0].nodeId, confidence: TIER_CONFIDENCE[resolved.tier] };
}
return generateId(fallbackLabel, fallbackKey ?? name);
// Unresolved: use global-tier confidence as fallback
return { id: generateId(fallbackLabel, fallbackKey ?? name), confidence: TIER_CONFIDENCE['global'] };
};
export const processHeritage = async (
@ -163,16 +170,16 @@ export const processHeritage = async (
const { type: relType, idPrefix } = resolveExtendsType(parentClassName, file.path, ctx, language);
const childId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
const parentId = resolveHeritageId(parentClassName, file.path, ctx, idPrefix);
const child = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
const parent = resolveHeritageId(parentClassName, file.path, ctx, idPrefix);
if (childId && parentId && childId !== parentId) {
if (child.id && parent.id && child.id !== parent.id) {
graph.addRelationship({
id: generateId(relType, `${childId}->${parentId}`),
sourceId: childId,
targetId: parentId,
id: generateId(relType, `${child.id}->${parent.id}`),
sourceId: child.id,
targetId: parent.id,
type: relType,
confidence: 1.0,
confidence: Math.sqrt(child.confidence * parent.confidence),
reason: '',
});
}
@ -183,16 +190,16 @@ export const processHeritage = async (
const className = captureMap['heritage.class'].text;
const interfaceName = captureMap['heritage.implements'].text;
const classId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
const interfaceId = resolveHeritageId(interfaceName, file.path, ctx, 'Interface');
const cls = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
const iface = resolveHeritageId(interfaceName, file.path, ctx, 'Interface');
if (classId && interfaceId) {
if (cls.id && iface.id) {
graph.addRelationship({
id: generateId('IMPLEMENTS', `${classId}->${interfaceId}`),
sourceId: classId,
targetId: interfaceId,
id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`),
sourceId: cls.id,
targetId: iface.id,
type: 'IMPLEMENTS',
confidence: 1.0,
confidence: Math.sqrt(cls.confidence * iface.confidence),
reason: '',
});
}
@ -203,16 +210,16 @@ export const processHeritage = async (
const structName = captureMap['heritage.class'].text;
const traitName = captureMap['heritage.trait'].text;
const structId = resolveHeritageId(structName, file.path, ctx, 'Struct', `${file.path}:${structName}`);
const traitId = resolveHeritageId(traitName, file.path, ctx, 'Trait');
const strct = resolveHeritageId(structName, file.path, ctx, 'Struct', `${file.path}:${structName}`);
const trait = resolveHeritageId(traitName, file.path, ctx, 'Trait');
if (structId && traitId) {
if (strct.id && trait.id) {
graph.addRelationship({
id: generateId('IMPLEMENTS', `${structId}->${traitId}`),
sourceId: structId,
targetId: traitId,
id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}`),
sourceId: strct.id,
targetId: trait.id,
type: 'IMPLEMENTS',
confidence: 1.0,
confidence: Math.sqrt(strct.confidence * trait.confidence),
reason: 'trait-impl',
});
}
@ -256,44 +263,44 @@ export const processHeritageFromExtracted = async (
if (!fileLanguage) continue;
const { type: relType, idPrefix } = resolveExtendsType(h.parentName, h.filePath, ctx, fileLanguage);
const childId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
const parentId = resolveHeritageId(h.parentName, h.filePath, ctx, idPrefix);
const child = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
const parent = resolveHeritageId(h.parentName, h.filePath, ctx, idPrefix);
if (childId && parentId && childId !== parentId) {
if (child.id && parent.id && child.id !== parent.id) {
graph.addRelationship({
id: generateId(relType, `${childId}->${parentId}`),
sourceId: childId,
targetId: parentId,
id: generateId(relType, `${child.id}->${parent.id}`),
sourceId: child.id,
targetId: parent.id,
type: relType,
confidence: 1.0,
confidence: Math.sqrt(child.confidence * parent.confidence),
reason: '',
});
}
} else if (h.kind === 'implements') {
const classId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
const interfaceId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Interface');
const cls = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
const iface = resolveHeritageId(h.parentName, h.filePath, ctx, 'Interface');
if (classId && interfaceId) {
if (cls.id && iface.id) {
graph.addRelationship({
id: generateId('IMPLEMENTS', `${classId}->${interfaceId}`),
sourceId: classId,
targetId: interfaceId,
id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`),
sourceId: cls.id,
targetId: iface.id,
type: 'IMPLEMENTS',
confidence: 1.0,
confidence: Math.sqrt(cls.confidence * iface.confidence),
reason: '',
});
}
} else if (h.kind === 'trait-impl' || h.kind === 'include' || h.kind === 'extend' || h.kind === 'prepend') {
const structId = resolveHeritageId(h.className, h.filePath, ctx, 'Struct', `${h.filePath}:${h.className}`);
const traitId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait');
const strct = resolveHeritageId(h.className, h.filePath, ctx, 'Struct', `${h.filePath}:${h.className}`);
const trait = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait');
if (structId && traitId) {
if (strct.id && trait.id) {
graph.addRelationship({
id: generateId('IMPLEMENTS', `${structId}->${traitId}:${h.kind}`),
sourceId: structId,
targetId: traitId,
id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}:${h.kind}`),
sourceId: strct.id,
targetId: trait.id,
type: 'IMPLEMENTS',
confidence: 1.0,
confidence: Math.sqrt(strct.confidence * trait.confidence),
reason: h.kind,
});
}

View file

@ -209,7 +209,7 @@ function c3Linearize(
// ---------------------------------------------------------------------------
type MethodDef = { classId: string; className: string; methodId: string };
type Resolution = { resolvedTo: string | null; reason: string };
type Resolution = { resolvedTo: string | null; reason: string; confidence: number };
/** Resolve by MRO order — first ancestor in linearized order wins. */
function resolveByMroOrder(
@ -224,10 +224,11 @@ function resolveByMroOrder(
return {
resolvedTo: match.methodId,
reason: `${reasonPrefix}: ${match.className}::${methodName}`,
confidence: 0.9, // MRO-ordered resolution
};
}
}
return { resolvedTo: defs[0].methodId, reason: `${reasonPrefix} fallback: first definition` };
return { resolvedTo: defs[0].methodId, reason: `${reasonPrefix} fallback: first definition`, confidence: 0.7 };
}
function resolveCsharpJava(
@ -251,6 +252,7 @@ function resolveCsharpJava(
return {
resolvedTo: classDefs[0].methodId,
reason: `class method wins: ${classDefs[0].className}::${methodName}`,
confidence: 0.95, // Class method is authoritative
};
}
@ -258,6 +260,7 @@ function resolveCsharpJava(
return {
resolvedTo: null,
reason: `ambiguous: ${methodName} defined in multiple interfaces: ${interfaceDefs.map(d => d.className).join(', ')}`,
confidence: 0.5,
};
}
@ -265,10 +268,11 @@ function resolveCsharpJava(
return {
resolvedTo: interfaceDefs[0].methodId,
reason: `single interface default: ${interfaceDefs[0].className}::${methodName}`,
confidence: 0.85, // Single interface, unambiguous
};
}
return { resolvedTo: null, reason: 'no resolution found' };
return { resolvedTo: null, reason: 'no resolution found', confidence: 0.5 };
}
// ---------------------------------------------------------------------------
@ -376,6 +380,7 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
resolution = {
resolvedTo: null,
reason: `Rust requires qualified syntax: <Type as Trait>::${methodName}()`,
confidence: 0.5,
};
break;
default:
@ -402,7 +407,7 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
sourceId: classId,
targetId: resolution.resolvedTo,
type: 'OVERRIDES',
confidence: 1.0,
confidence: resolution.confidence,
reason: resolution.reason,
});
overrideEdges++;

View file

@ -49,6 +49,44 @@ export const VALID_NODE_LABELS = new Set([
/** Valid relation types for impact analysis filtering */
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']);
/**
* Per-relation-type confidence floor for impact analysis.
*
* When the graph stores a relation with a confidence value, that stored
* value is used as-is (it reflects resolution-tier accuracy from analysis
* time). This map provides the floor for each edge type when no stored
* confidence is available, and is also used for display / tooltip hints.
*
* Rationale:
* CALLS / IMPORTS – direct, strongly-typed references → 0.9
* EXTENDS – class hierarchy, statically verifiable → 0.85
* IMPLEMENTS – interface contract, statically verifiable → 0.85
* OVERRIDES – method override, statically verifiable → 0.85
* HAS_METHOD – structural containment → 0.95
* HAS_PROPERTY – structural containment → 0.95
* ACCESSES – field read/write, may be indirect → 0.8
* CONTAINS – folder/file containment → 0.95
* (unknown type) – conservative fallback → 0.5
*/
export const IMPACT_RELATION_CONFIDENCE: Readonly<Record<string, number>> = {
CALLS: 0.9,
IMPORTS: 0.9,
EXTENDS: 0.85,
IMPLEMENTS: 0.85,
OVERRIDES: 0.85,
HAS_METHOD: 0.95,
HAS_PROPERTY: 0.95,
ACCESSES: 0.8,
CONTAINS: 0.95,
};
/**
* Return the confidence floor for a given relation type.
* Falls back to 0.5 for unknown types so they are not silently elevated.
*/
const confidenceForRelType = (relType: string | undefined): number =>
IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
/** Regex to detect write operations in user-supplied Cypher queries */
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
@ -1445,14 +1483,22 @@ export class LocalBackend {
if (!visited.has(relId)) {
visited.add(relId);
nextFrontier.push(relId);
const storedConfidence = rel.confidence ?? rel[6];
const relationType = rel.relType || rel[5];
// Prefer the stored confidence from the graph (set at analysis time);
// fall back to the per-type floor for edges without a stored value.
const effectiveConfidence =
typeof storedConfidence === 'number' && storedConfidence > 0
? storedConfidence
: confidenceForRelType(relationType);
impacted.push({
depth,
id: relId,
name: rel.name || rel[2],
type: rel.type || rel[3],
filePath,
relationType: rel.relType || rel[5],
confidence: rel.confidence || rel[6] || 1.0,
relationType,
confidence: effectiveConfidence,
});
}
}

View file

@ -31,7 +31,7 @@ describe('processHeritageFromExtracted', () => {
expect(rels).toHaveLength(1);
expect(rels[0].sourceId).toBe('Class:src/admin.ts:AdminUser');
expect(rels[0].targetId).toBe('Class:src/user.ts:User');
expect(rels[0].confidence).toBe(1.0);
expect(rels[0].confidence).toBeCloseTo(0.689202); // geometric mean: sqrt(same-file * global)
});
it('uses generated ID when class not in symbol table', async () => {

View file

@ -0,0 +1,140 @@
/**
* Unit Tests: Impact confidence per relation type (#412)
*
* Tests IMPACT_RELATION_CONFIDENCE and confidenceForRelType (tested
* indirectly) to verify that:
*
* 1. Each known relation type maps to the expected confidence floor.
* 2. Unknown / undefined relation types fall back to 0.5 (conservative).
* 3. Stored graph confidence is preferred over the type-based floor.
* 4. Confidence values are in the valid 0–1 range.
*/
import { describe, it, expect } from 'vitest';
import { IMPACT_RELATION_CONFIDENCE, VALID_RELATION_TYPES } from '../../src/mcp/local/local-backend.js';
// ─── IMPACT_RELATION_CONFIDENCE — value assertions ────────────────────────
describe('IMPACT_RELATION_CONFIDENCE', () => {
it('CALLS has confidence 0.9 (direct reference)', () => {
expect(IMPACT_RELATION_CONFIDENCE['CALLS']).toBe(0.9);
});
it('IMPORTS has confidence 0.9 (direct reference)', () => {
expect(IMPACT_RELATION_CONFIDENCE['IMPORTS']).toBe(0.9);
});
it('EXTENDS has confidence 0.85 (statically verifiable inheritance)', () => {
expect(IMPACT_RELATION_CONFIDENCE['EXTENDS']).toBe(0.85);
});
it('IMPLEMENTS has confidence 0.85 (statically verifiable contract)', () => {
expect(IMPACT_RELATION_CONFIDENCE['IMPLEMENTS']).toBe(0.85);
});
it('OVERRIDES has confidence 0.85 (statically verifiable override)', () => {
expect(IMPACT_RELATION_CONFIDENCE['OVERRIDES']).toBe(0.85);
});
it('HAS_METHOD has confidence 0.95 (structural containment)', () => {
expect(IMPACT_RELATION_CONFIDENCE['HAS_METHOD']).toBe(0.95);
});
it('HAS_PROPERTY has confidence 0.95 (structural containment)', () => {
expect(IMPACT_RELATION_CONFIDENCE['HAS_PROPERTY']).toBe(0.95);
});
it('ACCESSES has confidence 0.8 (may be indirect read/write)', () => {
expect(IMPACT_RELATION_CONFIDENCE['ACCESSES']).toBe(0.8);
});
it('CONTAINS has confidence 0.95 (folder/file structural containment)', () => {
expect(IMPACT_RELATION_CONFIDENCE['CONTAINS']).toBe(0.95);
});
it('all defined confidence values are in the valid [0, 1] range', () => {
for (const [type, confidence] of Object.entries(IMPACT_RELATION_CONFIDENCE)) {
expect(confidence, `${type} confidence out of range`).toBeGreaterThanOrEqual(0);
expect(confidence, `${type} confidence out of range`).toBeLessThanOrEqual(1);
}
});
});
// ─── confidenceForRelType — fallback semantics ────────────────────────────
//
// confidenceForRelType is not exported, so we replicate its logic here to
// verify the semantics that the production code must uphold.
const confidenceForRelType = (relType: string | undefined): number =>
IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
describe('confidenceForRelType', () => {
it('returns the correct floor for known types', () => {
expect(confidenceForRelType('CALLS')).toBe(0.9);
expect(confidenceForRelType('IMPORTS')).toBe(0.9);
expect(confidenceForRelType('EXTENDS')).toBe(0.85);
expect(confidenceForRelType('IMPLEMENTS')).toBe(0.85);
expect(confidenceForRelType('OVERRIDES')).toBe(0.85);
expect(confidenceForRelType('HAS_METHOD')).toBe(0.95);
expect(confidenceForRelType('HAS_PROPERTY')).toBe(0.95);
expect(confidenceForRelType('ACCESSES')).toBe(0.8);
expect(confidenceForRelType('CONTAINS')).toBe(0.95);
});
it('returns 0.5 for unknown relation types', () => {
expect(confidenceForRelType('UNKNOWN_EDGE')).toBe(0.5);
expect(confidenceForRelType('SOME_FUTURE_TYPE')).toBe(0.5);
expect(confidenceForRelType('')).toBe(0.5);
});
it('returns 0.5 for undefined relation type', () => {
expect(confidenceForRelType(undefined)).toBe(0.5);
});
});
// ─── Effective confidence selection — stored value wins ───────────────────
//
// Verify the priority logic: stored graph confidence beats the type floor.
describe('effective confidence selection (stored vs type-floor)', () => {
const pickConfidence = (storedConfidence: number | undefined, relationType: string): number => {
return typeof storedConfidence === 'number' && storedConfidence > 0
? storedConfidence
: confidenceForRelType(relationType);
};
it('uses stored confidence when it is a positive number', () => {
expect(pickConfidence(0.95, 'CALLS')).toBe(0.95);
expect(pickConfidence(0.7, 'EXTENDS')).toBe(0.7);
expect(pickConfidence(0.3, 'ACCESSES')).toBe(0.3);
});
it('falls back to type floor when stored confidence is undefined', () => {
expect(pickConfidence(undefined, 'CALLS')).toBe(0.9);
expect(pickConfidence(undefined, 'EXTENDS')).toBe(0.85);
expect(pickConfidence(undefined, 'UNKNOWN')).toBe(0.5);
});
it('falls back to type floor when stored confidence is 0 (not a valid confidence)', () => {
// 0 means "no confidence stored", not "zero confidence"
expect(pickConfidence(0, 'CALLS')).toBe(0.9);
expect(pickConfidence(0, 'IMPLEMENTS')).toBe(0.85);
});
it('stored confidence can be lower than the type floor (respects analysis result)', () => {
// If analysis determined a low-confidence match, honour it
expect(pickConfidence(0.5, 'HAS_METHOD')).toBe(0.5); // floor is 0.95, stored wins
});
});
// ─── VALID_RELATION_TYPES consistency ─────────────────────────────────────
describe('IMPACT_RELATION_CONFIDENCE vs VALID_RELATION_TYPES', () => {
it('every key in IMPACT_RELATION_CONFIDENCE except CONTAINS is in VALID_RELATION_TYPES', () => {
// CONTAINS is a graph-internal structural type, not exposed in impact filters
const skipInValid = new Set(['CONTAINS']);
for (const type of Object.keys(IMPACT_RELATION_CONFIDENCE)) {
if (skipInValid.has(type)) continue;
expect(VALID_RELATION_TYPES.has(type), `${type} missing from VALID_RELATION_TYPES`).toBe(true);
}
});
});