Skip to content
Open
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
3 changes: 2 additions & 1 deletion libs/knowledge-graph/code-anchors/audit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Claim, ClaimCodeAnchor } from "../claim.js";
import { anchorContentDrift } from "./freshness.js";
import { anchorFingerprintKey, fingerprintAnchor } from "./fingerprint.js";
import { CodeAnchorResolver } from "./resolver.js";
import type { ClaimAnchorAuditResult } from "./types.js";
Expand Down Expand Up @@ -73,5 +74,5 @@ async function hasDrifted(
const stored = baseline[anchorFingerprintKey(anchor)];
if (stored === undefined) return false;
const current = await fingerprintAnchor(repoRoot, anchor, resolver);
return current !== undefined && current !== stored;
return anchorContentDrift(stored, current);
}
41 changes: 41 additions & 0 deletions libs/knowledge-graph/code-anchors/freshness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { ResolvedCodeAnchor, ResolvedCodeAnchorStatus } from "./types.js";

export type StaleReason = "structural" | "content";

export interface AnchorCheck {
anchor: ResolvedCodeAnchor;
storedHash: string | undefined;
currentHash: string | undefined;
}

const structuralStatuses: ReadonlySet<ResolvedCodeAnchorStatus> = new Set([
"missing_file",
"missing_symbol",
"ambiguous_symbol",
]);

/** Return why a claim is proven stale, or undefined when staleness is not proven. */
export function classifyStale(checks: AnchorCheck[]): StaleReason | undefined {
if (checks.length === 0) return undefined;
if (checks.every((check) => isStructurallyBroken(check.anchor))) return "structural";
if (checks.some(hasContentDrift)) return "content";
return undefined;
}

export function isStructurallyBroken(anchor: ResolvedCodeAnchor): boolean {
return structuralStatuses.has(anchor.status);
}

/** True when both hashes are known and the current anchor code no longer matches the baseline. */
export function anchorContentDrift(
storedHash: string | undefined,
currentHash: string | undefined,
): boolean {
if (storedHash === undefined || currentHash === undefined) return false;
return currentHash !== storedHash;
}

function hasContentDrift(check: AnchorCheck): boolean {
if (isStructurallyBroken(check.anchor)) return false;
return anchorContentDrift(check.storedHash, check.currentHash);
}
49 changes: 49 additions & 0 deletions libs/knowledge-graph/graph-context/claim-freshness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { SqliteRepository } from "../../storage/sqlite/repository.js";
import { anchorFingerprintKey, fingerprintAnchor } from "../code-anchors/fingerprint.js";
import { classifyStale, isStructurallyBroken, type AnchorCheck } from "../code-anchors/freshness.js";
import { CodeAnchorResolver } from "../code-anchors/resolver.js";
import type { ResolvedCodeAnchor } from "../code-anchors/types.js";
import type { ClaimContextResult } from "./types.js";

type FingerprintReader = Pick<SqliteRepository, "readClaimAnchorFingerprints">;

export async function attachStaleClaims(
claims: ClaimContextResult[],
repository: FingerprintReader,
repoId: string,
repoRoot: string | undefined,
resolver: CodeAnchorResolver,
): Promise<ClaimContextResult[]> {
const candidates = claims.filter((claim) => claim.object.truth === "code_verified" && claim.code_anchors.length > 0);
const storedByClaim = repository.readClaimAnchorFingerprints(repoId, candidates.map((claim) => claim.object.id));

return Promise.all(claims.map(async (claim) => {
const stored = storedByClaim.get(claim.object.id);
if (stored === undefined || Object.keys(stored).length === 0) return claim;

const checks = await staleChecks(claim.code_anchors, stored, repoRoot, resolver);
const reason = classifyStale(checks);
return reason === undefined ? claim : { ...claim, freshness: { reason } };
}));
}

async function staleChecks(
anchors: ResolvedCodeAnchor[],
stored: Record<string, string>,
repoRoot: string | undefined,
resolver: CodeAnchorResolver,
): Promise<AnchorCheck[]> {
if (anchors.every(isStructurallyBroken)) {
return anchors.map((anchor) => ({ anchor, storedHash: stored[anchorFingerprintKey(anchor)], currentHash: undefined }));
}

return Promise.all(anchors.map(async (anchor) => {
const storedHash = stored[anchorFingerprintKey(anchor)];
const shouldHash = storedHash !== undefined && (anchor.status === "resolved" || anchor.status === "file_only");
return {
anchor,
storedHash,
currentHash: shouldHash ? await fingerprintAnchor(repoRoot, anchor, resolver) : undefined,
};
}));
}
23 changes: 17 additions & 6 deletions libs/knowledge-graph/graph-context/context-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { ClaimContextResult, ClaimEvidenceResult, ComponentContextResult, E
import { rankPacketResults, roundRankedSignals, selectGraphObjects } from "./packet-rank.js";
import { CodeAnchorResolver } from "../code-anchors/resolver.js";
import type { ResolvedCodeAnchor } from "../code-anchors/types.js";
import { attachStaleClaims } from "./claim-freshness.js";

export interface BuildGraphContextOptions {
warnOnCreatedEmbeddings?: boolean;
Expand Down Expand Up @@ -52,14 +53,15 @@ export class GraphContextBuilder {

const queryEmbedding = await embedder.embed(query);
const embeddings = this.loadEmbeddings(repoId, config);
return this.buildFromVectors(graph, query, queryEmbedding, embeddings, {
return this.buildFromVectors(repoId, graph, query, queryEmbedding, embeddings, {
...options,
config,
embeddingStatus,
});
}

async buildFromVectors(
repoId: string,
graph: GraphReadResult,
query: string,
queryEmbedding: number[],
Expand All @@ -85,29 +87,38 @@ export class GraphContextBuilder {
options.repoRoot,
options.resolveCodeAnchors ?? true,
);
const claimsWithFreshness = this.repository === undefined
? selectedClaims
: await attachStaleClaims(
selectedClaims,
this.repository,
repoId,
options.repoRoot,
this.codeAnchorResolver,
);
const selectedComponents = selectGraphObjects(
ranked.components,
selectedClaims,
claimsWithFreshness,
"component",
config,
) as ComponentContextResult[];
const selectedFlows = selectGraphObjects(
ranked.flows,
selectedClaims,
claimsWithFreshness,
"flow",
config,
) as FlowContextResult[];
const rankedResults = rankPacketResults(selectedClaims, selectedComponents, selectedFlows, graph, config);
const rankedResults = rankPacketResults(claimsWithFreshness, selectedComponents, selectedFlows, graph, config);

return {
query,
search_config_version: config.version,
embedding_status: options.embeddingStatus,
claims: selectedClaims,
claims: claimsWithFreshness,
components: selectedComponents,
flows: selectedFlows,
ranked_results: rankedResults,
sources: selectedEvidenceSources(selectedClaims),
sources: selectedEvidenceSources(claimsWithFreshness),
debug: {
ranked_results: rankedResults,
base_ranked_claims: baseRanked.claims.map((document, index) => toRankedDebugResult(document, index) as RankedContextDebugResult<Claim>),
Expand Down
61 changes: 53 additions & 8 deletions libs/knowledge-graph/graph-context/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,46 +8,66 @@ export function renderGraphContextMarkdown(result: GraphContextResult): string {
const rankedComponents = result.ranked_results.filter((item) => item.type === "component");
const rankedFlows = result.ranked_results.filter((item) => item.type === "flow");
const rankedClaims = result.ranked_results.filter((item) => item.type === "claim");
const staleClaims = rankedClaims.filter((claim) => claim.freshness !== undefined);
const liveClaims = rankedClaims.filter((claim) => claim.freshness === undefined);
const staleClaimIds = new Set(staleClaims.map((claim) => claim.object.id));
const componentsById = new Map(result.components.map((component) => [component.object.id, component.object.name]));
const flowsById = new Map(result.flows.map((flow) => [flow.object.id, flow.object.name]));
const content = [
"# Graph Context",
"",
"## Best Claims",
"",
...renderRankedClaims(rankedClaims, componentsById, flowsById),
...renderClaimSections(liveClaims, staleClaims, componentsById, flowsById),
"",
"## Related Components",
"",
...renderRankedComponents(rankedComponents),
...renderRankedComponents(rankedComponents, staleClaimIds),
"",
"## Related Flows",
"",
...renderRankedFlows(rankedFlows),
...renderRankedFlows(rankedFlows, staleClaimIds),
];

return lines(...content);
}

function renderClaimSections(
liveClaims: Array<Extract<RankedGraphContextResult, { type: "claim" }>>,
staleClaims: Array<Extract<RankedGraphContextResult, { type: "claim" }>>,
componentsById: Map<string, string>,
flowsById: Map<string, string>,
): string[] {
if (liveClaims.length === 0 && staleClaims.length > 0) {
return renderStaleClaims(staleClaims, componentsById, flowsById);
}
return [
"## Best Claims",
"",
...renderRankedClaims(liveClaims, componentsById, flowsById),
...renderStaleClaims(staleClaims, componentsById, flowsById),
];
}

function renderRankedComponents(
components: Array<Extract<RankedGraphContextResult, { type: "component" }>>,
staleClaimIds: Set<string>,
): string[] {
if (components.length === 0) return ["- None."];
return components.map((component, index) => {
const relation = component.context_relation === "additional" ? " additional" : "";
const anchor = component.object.code_anchor === undefined ? "" : ` Anchor: \`${component.object.code_anchor}\`.`;
const claims = component.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${component.matched_claim_ids.map((id) => `\`${id}\``).join(", ")}.`;
const claims = component.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${component.matched_claim_ids.map((id) => claimReference(id, staleClaimIds)).join(", ")}.`;
return `- ${index + 1}. ${component.object.name}${relation}. ID: \`${component.object.id}\`.${anchor}${claims}${provenanceLabel(component.object)}`;
});
}

function renderRankedFlows(
flows: Array<Extract<RankedGraphContextResult, { type: "flow" }>>,
staleClaimIds: Set<string>,
): string[] {
if (flows.length === 0) return ["- None."];
return flows.map((flow, index) => {
const relation = flow.context_relation === "additional" ? " additional" : "";
const claims = flow.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${flow.matched_claim_ids.map((id) => `\`${id}\``).join(", ")}.`;
const claims = flow.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${flow.matched_claim_ids.map((id) => claimReference(id, staleClaimIds)).join(", ")}.`;
return `- ${index + 1}. ${flow.object.name}${relation}. ID: \`${flow.object.id}\`.${claims}${provenanceLabel(flow.object)}`;
});
}
Expand All @@ -61,12 +81,13 @@ function renderRankedClaims(
return claims.flatMap((claim, index) => {
const anchors = claim.code_anchors.length === 0 ? "" : ` Anchor: ${claim.code_anchors.map(anchorLabel).join("; ")}.`;
const about = aboutLabel(claim.about, componentsById, flowsById);
const freshness = freshnessLabel(claim);
return [
`### ${index + 1}. ${claim.object.id}`,
"",
claim.object.text,
"",
`${anchors}${about}${provenanceLabel(claim.object)}`.trim(),
`${freshness}${anchors}${about}${provenanceLabel(claim.object)}`.trim(),
"",
];
});
Expand Down Expand Up @@ -124,6 +145,26 @@ function provenanceValues(provenance: ManagedObjectProvenance | ManagedObjectOri
return values;
}

function freshnessLabel(claim: Extract<RankedGraphContextResult, { type: "claim" }>): string {
return claim.freshness === undefined
? ""
: `[STALE: ${claim.freshness.reason} drift - re-verify against current code].`;
}

function renderStaleClaims(
claims: Array<Extract<RankedGraphContextResult, { type: "claim" }>>,
componentsById: Map<string, string>,
flowsById: Map<string, string>,
): string[] {
if (claims.length === 0) return [];
return [
"",
"## Needs re-verification",
"",
...renderRankedClaims(claims, componentsById, flowsById),
];
}

function anchorLabel(anchor: Extract<RankedGraphContextResult, { type: "claim" }>["code_anchors"][number]): string {
const base = anchor.symbol === undefined ? anchor.file : `${anchor.file}#${anchor.symbol}`;
if (anchor.status === "resolved" && anchor.start_line !== undefined) {
Expand All @@ -149,6 +190,10 @@ function aboutLabel(
return ` About: ${labels.join("; ")}.`;
}

function claimReference(id: string, staleClaimIds: Set<string>): string {
return staleClaimIds.has(id) ? `\`${id}\` (stale)` : `\`${id}\``;
}

function lines(...values: string[]): string {
return `${values.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
}
2 changes: 2 additions & 0 deletions libs/knowledge-graph/graph-context/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Claim } from "../claim.js";
import type { ResolvedCodeAnchor } from "../code-anchors/types.js";
import type { Component, Flow, Source } from "../schema.js";
import type { StaleReason } from "../code-anchors/freshness.js";

export interface EmbeddingStatus {
checked_objects: number;
Expand Down Expand Up @@ -47,6 +48,7 @@ export interface ClaimContextResult {
about: Array<{ type: "component" | "flow"; id: string }>;
evidence: ClaimEvidenceResult[];
code_anchors: ResolvedCodeAnchor[];
freshness?: { reason: StaleReason };
}

export interface GraphObjectContextResult<TObject> {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs",
"smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs",
"smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js && node scripts/check-freshness-foreground.js",
"test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js",
"test:reconciliation-code-evidence": "npm run build && node scripts/check-reconciliation-code-evidence.js",
"test:repo-installations": "npm run build && node scripts/check-repo-installations.js",
Expand Down
Loading