mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
Merge pull request #665 from proffesor-for-testing/feat/structural-retrieval-benchmark
feat(learning): add structural retrieval qualification
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Structural Retrieval Qualification
|
||||
|
||||
AQE includes a deterministic qualification corpus for measuring whether a ranker retrieves reusable test patterns across surface changes. It is an evaluation boundary; it does not change the production ranker.
|
||||
|
||||
`createStructuralRetrievalCorpus()` returns 100 query groups spanning rename, framework transfer, reordered steps, distractor terminology, and cross-domain transfer. Each group identifies gold relevance, a surface-similar wrong remedy, an environment-sensitive counterexample, and ordering constraints. Source families belong to exactly one of the train, calibration, or audit splits. The revision and SHA-256 lineage hash bind every pattern and query.
|
||||
|
||||
Pass one result for every query to `evaluateStructuralRetrieval()`. The report includes candidate recall@k, strict Hit@1, MRR, nDCG, signed gold-to-best-wrong margin, deterministic 95% bootstrap intervals, per-transformation support, ordering violations, and these attribution states:
|
||||
|
||||
- `not_retrieved`: the relevant pattern did not enter the top-k candidate set.
|
||||
- `retrieved_misranked`: it entered the set but a wrong pattern ranked first.
|
||||
- `ranked_not_used`: it ranked first but downstream execution did not apply it.
|
||||
- `used_no_benefit`: it was applied but the linked outcome did not improve.
|
||||
- `success`: it ranked first, was applied, and improved the linked outcome.
|
||||
|
||||
Use `createRetrievalReceipt()` to bind a run to its revision, representation version, embedding-space identity, candidates and scores, ranker version, applied pattern IDs, and downstream outcome reference. The receipt intentionally has no reasoning or chain-of-thought field. Missing provenance, non-finite scores, incomplete benchmark results, and corpus-lineage mismatches fail closed.
|
||||
|
||||
The initial lexical-overlap negative control is asserted by `tests/unit/learning/structural-retrieval.test.ts`. Production HNSW/FTS candidate composition and a recorded production baseline remain follow-up work under issue #653.
|
||||
@@ -79,6 +79,12 @@ export {
|
||||
mapQEDomainToAQE,
|
||||
QE_DOMAIN_LIST,
|
||||
QE_DOMAINS,
|
||||
createStructuralRetrievalCorpus,
|
||||
verifyStructuralRetrievalCorpus,
|
||||
evaluateStructuralRetrieval,
|
||||
diagnoseRetrieval,
|
||||
createRetrievalReceipt,
|
||||
STRUCTURAL_RETRIEVAL_TRANSFORMATIONS,
|
||||
} from './learning';
|
||||
export type {
|
||||
QEPattern,
|
||||
@@ -92,6 +98,19 @@ export type {
|
||||
PatternSearchResult,
|
||||
QERoutingRequest,
|
||||
QERoutingResult,
|
||||
StructuralTransformation,
|
||||
CorpusSplit,
|
||||
StructuralPattern,
|
||||
StructuralRetrievalQuery,
|
||||
StructuralRetrievalCorpus,
|
||||
RankedCandidate,
|
||||
RetrievalDiagnostic,
|
||||
QueryRetrievalResult,
|
||||
MetricEstimate,
|
||||
RetrievalMetricSet,
|
||||
StructuralRetrievalReport,
|
||||
RetrievalReceipt,
|
||||
CreateRetrievalReceiptInput,
|
||||
} from './learning';
|
||||
|
||||
// Feedback Module - Quality Feedback Loop (ADR-023)
|
||||
|
||||
@@ -101,6 +101,8 @@ export type {
|
||||
LearningSegment,
|
||||
} from './learning-evidence-admission.js';
|
||||
|
||||
export * from './structural-retrieval/index.js';
|
||||
|
||||
export type {
|
||||
QEDomain,
|
||||
QEPatternType,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type {
|
||||
CorpusSplit,
|
||||
StructuralPattern,
|
||||
StructuralRetrievalCorpus,
|
||||
StructuralRetrievalQuery,
|
||||
StructuralTransformation,
|
||||
} from './types.js';
|
||||
|
||||
const REVISION = 'aqe-structural-transfer-2026-09-06';
|
||||
const TRANSFORMATIONS: StructuralTransformation[] = [
|
||||
'rename', 'framework-transfer', 'reordered-steps',
|
||||
'distractor-terminology', 'cross-domain-transfer',
|
||||
];
|
||||
|
||||
interface FamilySeed {
|
||||
mechanism: string;
|
||||
invariant: string;
|
||||
action: string;
|
||||
wrongAction: string;
|
||||
oracle: string;
|
||||
domain: string;
|
||||
framework: string;
|
||||
}
|
||||
|
||||
const SEEDS: FamilySeed[] = [
|
||||
['async-race', 'observe completion before assertion', 'await the operation', 'increase the timeout', 'completion probe', 'unit', 'vitest'],
|
||||
['stale-cache', 'invalidate on every state mutation', 'invalidate the cache', 'retry the read', 'freshness assertion', 'integration', 'redis'],
|
||||
['transaction-leak', 'rollback isolates each test', 'rollback the transaction', 'truncate a shared table', 'row-count check', 'database', 'postgres'],
|
||||
['clock-skew', 'compare times in one clock domain', 'inject a monotonic clock', 'widen the time window', 'clock boundary check', 'unit', 'node'],
|
||||
['event-loss', 'subscribe before publishing', 'register the listener first', 'publish twice', 'delivery assertion', 'events', 'eventemitter'],
|
||||
['resource-leak', 'release resources on every exit', 'close in a finally block', 'raise the pool limit', 'handle-count check', 'integration', 'playwright'],
|
||||
['eventual-consistency', 'poll the observable state', 'poll with a bounded deadline', 'sleep for a fixed delay', 'state convergence', 'distributed', 'kubernetes'],
|
||||
['identity-confusion', 'compare canonical identities', 'normalize the identifier', 'use display names', 'identity equality', 'security', 'oauth'],
|
||||
['partial-write', 'publish only complete state', 'commit through an atomic swap', 'ignore missing fields', 'atomicity check', 'filesystem', 'node'],
|
||||
['order-dependence', 'tests start from isolated state', 'reset state in setup', 'force alphabetical order', 'shuffle repeat', 'unit', 'jest'],
|
||||
['schema-drift', 'validate at the boundary', 'reject the incompatible schema', 'cast the payload', 'schema validator', 'api', 'zod'],
|
||||
['backpressure-loss', 'producer respects consumer capacity', 'await the drain signal', 'increase buffer memory', 'loss counter', 'performance', 'streams'],
|
||||
['retry-duplication', 'retries preserve idempotency', 'attach an idempotency key', 'disable all retries', 'duplicate detector', 'api', 'fetch'],
|
||||
['permission-bypass', 'authorization precedes mutation', 'check permission before write', 'hide the user interface', 'denial assertion', 'security', 'express'],
|
||||
['encoding-mismatch', 'encode and decode with one charset', 'set UTF-8 explicitly', 'strip non-ASCII text', 'round-trip equality', 'api', 'json'],
|
||||
['pagination-gap', 'cursor advances from the last item', 'use the returned cursor', 'increase page size', 'set equality', 'api', 'graphql'],
|
||||
['cancellation-leak', 'cancellation propagates to children', 'forward the abort signal', 'wait for natural completion', 'abort observation', 'integration', 'abortcontroller'],
|
||||
['floating-point-boundary', 'compare numeric results by tolerance', 'use an error bound', 'round every input', 'relative-error check', 'unit', 'vitest'],
|
||||
['locale-dependence', 'format under an explicit locale', 'pass the locale explicitly', 'update the snapshot locally', 'multi-locale check', 'ui', 'intl'],
|
||||
['lock-starvation', 'lock acquisition remains fair', 'queue lock waiters', 'raise retry count', 'bounded-wait check', 'concurrency', 'mutex'],
|
||||
].map(([mechanism, invariant, action, wrongAction, oracle, domain, framework]) => ({
|
||||
mechanism, invariant, action, wrongAction, oracle, domain, framework,
|
||||
}));
|
||||
|
||||
function splitFor(index: number): CorpusSplit {
|
||||
if (index < 12) return 'train';
|
||||
if (index < 16) return 'calibration';
|
||||
return 'audit';
|
||||
}
|
||||
|
||||
function stableHash(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function queryText(seed: FamilySeed, transformation: StructuralTransformation, index: number): string {
|
||||
const object = `component-${index + 41}`;
|
||||
switch (transformation) {
|
||||
case 'rename': return `${object} intermittently fails; ensure ${seed.invariant} by applying: ${seed.action}`;
|
||||
case 'framework-transfer': return `In an alternative runtime, ${seed.mechanism} appears. Preserve this rule: ${seed.invariant}`;
|
||||
case 'reordered-steps': return `Verify with ${seed.oracle}; first arrange the fixture, then ${seed.action}; fault: ${seed.mechanism}`;
|
||||
case 'distractor-terminology': return `${seed.wrongAction} is tempting for ${seed.mechanism}, but the required invariant is ${seed.invariant}`;
|
||||
case 'cross-domain-transfer': return `A ${seed.domain} workflow moved to service-${index}; reuse the mechanism ${seed.mechanism} and ${seed.action}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createStructuralRetrievalCorpus(): StructuralRetrievalCorpus {
|
||||
const patterns: StructuralPattern[] = [];
|
||||
const queries: StructuralRetrievalQuery[] = [];
|
||||
SEEDS.forEach((seed, index) => {
|
||||
const familyId = `family-${String(index + 1).padStart(2, '0')}`;
|
||||
const correctId = `${familyId}-structural`;
|
||||
const distractorId = `${familyId}-surface-distractor`;
|
||||
const counterexampleId = `${familyId}-counterexample`;
|
||||
patterns.push(
|
||||
{ id: correctId, familyId, framework: seed.framework, domain: seed.domain,
|
||||
failureMechanism: seed.mechanism, invariant: seed.invariant, actions: [seed.action], oracle: seed.oracle,
|
||||
text: `${seed.framework} ${seed.domain} ${seed.mechanism}: ${seed.action}; verify ${seed.oracle}` },
|
||||
{ id: distractorId, familyId, framework: seed.framework, domain: seed.domain,
|
||||
failureMechanism: `symptom resembling ${seed.mechanism}`, invariant: 'mask the observed symptom',
|
||||
actions: [seed.wrongAction], oracle: 'single rerun',
|
||||
text: `${seed.framework} ${seed.domain} ${seed.mechanism}: ${seed.wrongAction}; quick ${seed.oracle}` },
|
||||
{ id: counterexampleId, familyId, framework: seed.framework, domain: seed.domain,
|
||||
failureMechanism: seed.mechanism, invariant: `environment forbids ${seed.action}`,
|
||||
actions: [`escalate instead of ${seed.action}`], oracle: 'environment constraint check',
|
||||
text: `${seed.mechanism} ${seed.action} ${seed.framework}, but environment forbids the action` },
|
||||
);
|
||||
TRANSFORMATIONS.forEach((transformation) => queries.push({
|
||||
id: `${familyId}-${transformation}`,
|
||||
familyId,
|
||||
split: splitFor(index),
|
||||
transformation,
|
||||
text: queryText(seed, transformation, index),
|
||||
relevantPatternIds: [correctId],
|
||||
mustNotRankAbove: [
|
||||
{ lowerPatternId: distractorId, higherPatternId: correctId },
|
||||
{ lowerPatternId: counterexampleId, higherPatternId: correctId },
|
||||
],
|
||||
}));
|
||||
});
|
||||
const payload = { schemaVersion: 'aqe-structural-retrieval/v1' as const, corpusRevision: REVISION, patterns, queries };
|
||||
return deepFreeze({ ...payload, lineageHash: stableHash(payload) });
|
||||
}
|
||||
|
||||
export const STRUCTURAL_RETRIEVAL_TRANSFORMATIONS = Object.freeze([...TRANSFORMATIONS]);
|
||||
|
||||
export function verifyStructuralRetrievalCorpus(corpus: StructuralRetrievalCorpus): boolean {
|
||||
const { lineageHash: _lineageHash, ...payload } = corpus;
|
||||
return stableHash(payload) === corpus.lineageHash;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
MetricEstimate, QueryRetrievalResult, RetrievalDiagnostic, RetrievalMetricSet,
|
||||
StructuralRetrievalCorpus, StructuralRetrievalQuery, StructuralRetrievalReport,
|
||||
} from './types.js';
|
||||
import { verifyStructuralRetrievalCorpus } from './corpus.js';
|
||||
|
||||
interface Scores { recall: number; hit: number; reciprocalRank: number; ndcg: number; margin: number }
|
||||
|
||||
function queryScores(query: StructuralRetrievalQuery, result: QueryRetrievalResult, k: number): Scores {
|
||||
const relevant = new Set(query.relevantPatternIds);
|
||||
const rank = result.candidates.findIndex(candidate => relevant.has(candidate.patternId));
|
||||
const topKHits = result.candidates.slice(0, k).filter(candidate => relevant.has(candidate.patternId)).length;
|
||||
const idealCount = Math.min(k, relevant.size);
|
||||
const dcg = result.candidates.slice(0, k).reduce(
|
||||
(sum, candidate, index) => sum + (relevant.has(candidate.patternId) ? 1 / Math.log2(index + 2) : 0), 0,
|
||||
);
|
||||
const idcg = Array.from({ length: idealCount }, (_, index) => 1 / Math.log2(index + 2)).reduce((a, b) => a + b, 0);
|
||||
const relevantScore = rank >= 0 ? result.candidates[rank]!.score : 0;
|
||||
const bestWrong = Math.max(0, ...result.candidates.filter(c => !relevant.has(c.patternId)).map(c => c.score));
|
||||
return {
|
||||
recall: relevant.size === 0 ? 0 : topKHits / relevant.size,
|
||||
hit: rank === 0 ? 1 : 0,
|
||||
reciprocalRank: rank < 0 ? 0 : 1 / (rank + 1),
|
||||
ndcg: idcg === 0 ? 0 : dcg / idcg,
|
||||
margin: relevantScore - bestWrong,
|
||||
};
|
||||
}
|
||||
|
||||
function mean(values: number[]): number {
|
||||
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function estimate(values: number[], seed: number): MetricEstimate {
|
||||
if (values.length === 0) return { value: 0, confidenceInterval: { lower: 0, upper: 0, level: 0.95 } };
|
||||
let state = seed >>> 0;
|
||||
const random = () => ((state = (1664525 * state + 1013904223) >>> 0) / 0x100000000);
|
||||
const bootstraps = Array.from({ length: 1000 }, () => mean(Array.from(
|
||||
{ length: values.length }, () => values[Math.floor(random() * values.length)]!,
|
||||
))).sort((a, b) => a - b);
|
||||
return {
|
||||
value: mean(values),
|
||||
confidenceInterval: {
|
||||
lower: bootstraps[Math.floor(bootstraps.length * 0.025)]!,
|
||||
upper: bootstraps[Math.floor(bootstraps.length * 0.975)]!,
|
||||
level: 0.95,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function metricSet(scores: Scores[]): RetrievalMetricSet {
|
||||
return {
|
||||
support: scores.length,
|
||||
recallAtK: estimate(scores.map(s => s.recall), 11),
|
||||
hitAt1: estimate(scores.map(s => s.hit), 13),
|
||||
mrr: estimate(scores.map(s => s.reciprocalRank), 17),
|
||||
ndcg: estimate(scores.map(s => s.ndcg), 19),
|
||||
top1Margin: estimate(scores.map(s => s.margin), 23),
|
||||
};
|
||||
}
|
||||
|
||||
export function diagnoseRetrieval(query: StructuralRetrievalQuery, result: QueryRetrievalResult, k: number): RetrievalDiagnostic {
|
||||
const relevant = new Set(query.relevantPatternIds);
|
||||
const relevantCandidates = result.candidates.slice(0, k).filter(c => relevant.has(c.patternId));
|
||||
if (relevantCandidates.length === 0) return 'not_retrieved';
|
||||
if (!relevant.has(result.candidates[0]?.patternId ?? '')) return 'retrieved_misranked';
|
||||
const applied = result.appliedPatternIds ?? [];
|
||||
if (applied.length === 0 || !applied.some(id => relevant.has(id))) return 'ranked_not_used';
|
||||
if (result.outcomeImproved !== true) return 'used_no_benefit';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
export function evaluateStructuralRetrieval(
|
||||
corpus: StructuralRetrievalCorpus,
|
||||
results: QueryRetrievalResult[],
|
||||
options: { rankerVersion: string; k?: number },
|
||||
): StructuralRetrievalReport {
|
||||
const k = options.k ?? 10;
|
||||
if (!Number.isInteger(k) || k < 1) throw new Error('k must be a positive integer');
|
||||
if (!verifyStructuralRetrievalCorpus(corpus)) throw new Error('corpus lineage hash mismatch');
|
||||
if (results.length !== corpus.queries.length) {
|
||||
throw new Error('results must contain exactly one entry for every corpus query');
|
||||
}
|
||||
const byQuery = new Map(results.map(result => [result.queryId, result]));
|
||||
if (byQuery.size !== corpus.queries.length || corpus.queries.some(query => !byQuery.has(query.id))) {
|
||||
throw new Error('results must contain exactly one entry for every corpus query');
|
||||
}
|
||||
const knownPatterns = new Set(corpus.patterns.map(pattern => pattern.id));
|
||||
for (const result of results) {
|
||||
const candidateIds = new Set<string>();
|
||||
for (const candidate of result.candidates) {
|
||||
if (!knownPatterns.has(candidate.patternId)) throw new Error(`unknown candidate pattern: ${candidate.patternId}`);
|
||||
if (!Number.isFinite(candidate.score)) throw new Error(`candidate score must be finite: ${candidate.patternId}`);
|
||||
if (candidateIds.has(candidate.patternId)) throw new Error(`duplicate candidate pattern: ${candidate.patternId}`);
|
||||
candidateIds.add(candidate.patternId);
|
||||
}
|
||||
for (const appliedId of result.appliedPatternIds ?? []) {
|
||||
if (!candidateIds.has(appliedId)) throw new Error(`applied pattern was not a candidate: ${appliedId}`);
|
||||
}
|
||||
}
|
||||
const rows = corpus.queries.map(query => {
|
||||
const result = byQuery.get(query.id)!;
|
||||
return { query, result, scores: queryScores(query, result, k) };
|
||||
});
|
||||
const diagnosticNames: RetrievalDiagnostic[] = [
|
||||
'success', 'not_retrieved', 'retrieved_misranked', 'ranked_not_used', 'used_no_benefit',
|
||||
];
|
||||
const diagnostics = Object.fromEntries(diagnosticNames.map(name => [name, 0])) as Record<RetrievalDiagnostic, number>;
|
||||
for (const row of rows) diagnostics[diagnoseRetrieval(row.query, row.result, k)] += 1;
|
||||
const relationViolations = rows.reduce((count, { query, result }) => {
|
||||
const ranks = new Map(result.candidates.map((candidate, index) => [candidate.patternId, index]));
|
||||
return count + query.mustNotRankAbove.filter(relation => {
|
||||
const low = ranks.get(relation.lowerPatternId);
|
||||
const high = ranks.get(relation.higherPatternId);
|
||||
return low !== undefined && (high === undefined || low < high);
|
||||
}).length;
|
||||
}, 0);
|
||||
const slices: StructuralRetrievalReport['slices'] = {};
|
||||
for (const transformation of new Set(corpus.queries.map(query => query.transformation))) {
|
||||
slices[transformation] = metricSet(rows.filter(row => row.query.transformation === transformation).map(row => row.scores));
|
||||
}
|
||||
return {
|
||||
corpusRevision: corpus.corpusRevision,
|
||||
corpusLineageHash: corpus.lineageHash,
|
||||
rankerVersion: options.rankerVersion,
|
||||
k,
|
||||
overall: metricSet(rows.map(row => row.scores)),
|
||||
slices,
|
||||
diagnostics,
|
||||
relationViolations,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
createStructuralRetrievalCorpus,
|
||||
verifyStructuralRetrievalCorpus,
|
||||
STRUCTURAL_RETRIEVAL_TRANSFORMATIONS,
|
||||
} from './corpus.js';
|
||||
export { diagnoseRetrieval, evaluateStructuralRetrieval } from './evaluate.js';
|
||||
export { createRetrievalReceipt } from './receipt.js';
|
||||
export type * from './types.js';
|
||||
export type { CreateRetrievalReceiptInput } from './receipt.js';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { RankedCandidate, RetrievalReceipt } from './types.js';
|
||||
|
||||
export interface CreateRetrievalReceiptInput {
|
||||
revision: string;
|
||||
queryId: string;
|
||||
queryRepresentationVersion: string;
|
||||
embeddingSpaceId: string;
|
||||
rankerVersion: string;
|
||||
candidates: RankedCandidate[];
|
||||
appliedPatternIds?: string[];
|
||||
outcomeRef?: string | null;
|
||||
outcomeImproved?: boolean | null;
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createRetrievalReceipt(input: CreateRetrievalReceiptInput): RetrievalReceipt {
|
||||
if (!input.revision || !input.queryId || !input.queryRepresentationVersion || !input.embeddingSpaceId || !input.rankerVersion) {
|
||||
throw new Error('retrieval receipt provenance fields must be non-empty');
|
||||
}
|
||||
const candidateIds = new Set(input.candidates.map(candidate => candidate.patternId));
|
||||
if (candidateIds.size !== input.candidates.length || input.candidates.some(candidate => !candidate.patternId || !Number.isFinite(candidate.score))) {
|
||||
throw new Error('retrieval receipt candidates must have an id and finite score');
|
||||
}
|
||||
if ((input.appliedPatternIds ?? []).some(id => !candidateIds.has(id))) {
|
||||
throw new Error('retrieval receipt applied patterns must be present in candidates');
|
||||
}
|
||||
const payload = {
|
||||
schemaVersion: 'aqe-retrieval-receipt/v1' as const,
|
||||
revision: input.revision,
|
||||
queryId: input.queryId,
|
||||
queryRepresentationVersion: input.queryRepresentationVersion,
|
||||
embeddingSpaceId: input.embeddingSpaceId,
|
||||
rankerVersion: input.rankerVersion,
|
||||
candidates: input.candidates.map(candidate => ({ ...candidate })),
|
||||
appliedPatternIds: [...(input.appliedPatternIds ?? [])],
|
||||
outcomeRef: input.outcomeRef ?? null,
|
||||
outcomeImproved: input.outcomeImproved ?? null,
|
||||
};
|
||||
const receiptId = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
return deepFreeze({ ...payload, receiptId });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export type StructuralTransformation =
|
||||
| 'rename'
|
||||
| 'framework-transfer'
|
||||
| 'reordered-steps'
|
||||
| 'distractor-terminology'
|
||||
| 'cross-domain-transfer';
|
||||
|
||||
export type CorpusSplit = 'train' | 'calibration' | 'audit';
|
||||
|
||||
export interface StructuralPattern {
|
||||
id: string;
|
||||
familyId: string;
|
||||
framework: string;
|
||||
domain: string;
|
||||
failureMechanism: string;
|
||||
invariant: string;
|
||||
actions: string[];
|
||||
oracle: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface StructuralRetrievalQuery {
|
||||
id: string;
|
||||
familyId: string;
|
||||
split: CorpusSplit;
|
||||
transformation: StructuralTransformation;
|
||||
text: string;
|
||||
relevantPatternIds: string[];
|
||||
mustNotRankAbove: Array<{ lowerPatternId: string; higherPatternId: string }>;
|
||||
}
|
||||
|
||||
export interface StructuralRetrievalCorpus {
|
||||
schemaVersion: 'aqe-structural-retrieval/v1';
|
||||
corpusRevision: string;
|
||||
lineageHash: string;
|
||||
patterns: StructuralPattern[];
|
||||
queries: StructuralRetrievalQuery[];
|
||||
}
|
||||
|
||||
export interface RankedCandidate {
|
||||
patternId: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export type RetrievalDiagnostic =
|
||||
| 'success'
|
||||
| 'not_retrieved'
|
||||
| 'retrieved_misranked'
|
||||
| 'ranked_not_used'
|
||||
| 'used_no_benefit';
|
||||
|
||||
export interface QueryRetrievalResult {
|
||||
queryId: string;
|
||||
candidates: RankedCandidate[];
|
||||
appliedPatternIds?: string[];
|
||||
outcomeImproved?: boolean;
|
||||
}
|
||||
|
||||
export interface MetricEstimate {
|
||||
value: number;
|
||||
confidenceInterval: { lower: number; upper: number; level: 0.95 };
|
||||
}
|
||||
|
||||
export interface RetrievalMetricSet {
|
||||
support: number;
|
||||
recallAtK: MetricEstimate;
|
||||
hitAt1: MetricEstimate;
|
||||
mrr: MetricEstimate;
|
||||
ndcg: MetricEstimate;
|
||||
top1Margin: MetricEstimate;
|
||||
}
|
||||
|
||||
export interface StructuralRetrievalReport {
|
||||
corpusRevision: string;
|
||||
corpusLineageHash: string;
|
||||
rankerVersion: string;
|
||||
k: number;
|
||||
overall: RetrievalMetricSet;
|
||||
slices: Partial<Record<StructuralTransformation, RetrievalMetricSet>>;
|
||||
diagnostics: Record<RetrievalDiagnostic, number>;
|
||||
relationViolations: number;
|
||||
}
|
||||
|
||||
export interface RetrievalReceipt {
|
||||
schemaVersion: 'aqe-retrieval-receipt/v1';
|
||||
receiptId: string;
|
||||
revision: string;
|
||||
queryId: string;
|
||||
queryRepresentationVersion: string;
|
||||
embeddingSpaceId: string;
|
||||
rankerVersion: string;
|
||||
candidates: RankedCandidate[];
|
||||
appliedPatternIds: string[];
|
||||
outcomeRef: string | null;
|
||||
outcomeImproved: boolean | null;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createRetrievalReceipt,
|
||||
createStructuralRetrievalCorpus,
|
||||
diagnoseRetrieval,
|
||||
evaluateStructuralRetrieval,
|
||||
} from '../../../src/learning/structural-retrieval/index.js';
|
||||
import type {
|
||||
QueryRetrievalResult,
|
||||
StructuralPattern,
|
||||
StructuralRetrievalQuery,
|
||||
} from '../../../src/learning/structural-retrieval/index.js';
|
||||
|
||||
function tokens(text: string): Set<string> {
|
||||
return new Set(text.toLowerCase().split(/[^a-z0-9]+/).filter(token => token.length > 2));
|
||||
}
|
||||
|
||||
function lexicalScore(query: string, pattern: StructuralPattern): number {
|
||||
const queryTokens = tokens(query);
|
||||
const patternTokens = tokens(pattern.text);
|
||||
return [...queryTokens].filter(token => patternTokens.has(token)).length / Math.max(1, queryTokens.size);
|
||||
}
|
||||
|
||||
function lexicalResults(): QueryRetrievalResult[] {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
return corpus.queries.map(query => ({
|
||||
queryId: query.id,
|
||||
candidates: corpus.patterns
|
||||
.map(pattern => ({ patternId: pattern.id, score: lexicalScore(query.text, pattern) }))
|
||||
.sort((a, b) => b.score - a.score || a.patternId.localeCompare(b.patternId))
|
||||
.slice(0, 10),
|
||||
}));
|
||||
}
|
||||
|
||||
describe('structural retrieval qualification corpus', () => {
|
||||
it('freezes a lineage-tracked corpus with 100 queries and five transformations', () => {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
expect(corpus.schemaVersion).toBe('aqe-structural-retrieval/v1');
|
||||
expect(corpus.lineageHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(createStructuralRetrievalCorpus().lineageHash).toBe(corpus.lineageHash);
|
||||
expect(corpus.queries).toHaveLength(100);
|
||||
expect(new Set(corpus.queries.map(query => query.transformation))).toHaveLength(5);
|
||||
expect(Object.isFrozen(corpus)).toBe(true);
|
||||
expect(Object.isFrozen(corpus.queries)).toBe(true);
|
||||
expect(Object.isFrozen(corpus.queries[0]!.mustNotRankAbove)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps source families disjoint across train, calibration, and audit splits', () => {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
const families = new Map<string, Set<string>>();
|
||||
for (const query of corpus.queries) {
|
||||
const splits = families.get(query.familyId) ?? new Set<string>();
|
||||
splits.add(query.split);
|
||||
families.set(query.familyId, splits);
|
||||
}
|
||||
expect([...families.values()].every(splits => splits.size === 1)).toBe(true);
|
||||
expect(new Set(corpus.queries.map(query => query.split))).toEqual(new Set(['train', 'calibration', 'audit']));
|
||||
});
|
||||
|
||||
it('includes hard distractors, counterexamples, gold relevance, and ordering constraints', () => {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
expect(corpus.queries.every(query => query.relevantPatternIds.length > 0)).toBe(true);
|
||||
expect(corpus.queries.every(query => query.mustNotRankAbove.length === 2)).toBe(true);
|
||||
expect(corpus.patterns.filter(pattern => pattern.id.endsWith('surface-distractor'))).toHaveLength(20);
|
||||
expect(corpus.patterns.filter(pattern => pattern.id.endsWith('counterexample'))).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('structural retrieval metrics and diagnostics', () => {
|
||||
it('reports perfect ranking metrics, slices, attribution, and relation compliance', () => {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
const results = corpus.queries.map(query => ({
|
||||
queryId: query.id,
|
||||
candidates: [
|
||||
{ patternId: query.relevantPatternIds[0]!, score: 0.9 },
|
||||
{ patternId: query.mustNotRankAbove[0]!.lowerPatternId, score: 0.3 },
|
||||
],
|
||||
appliedPatternIds: [query.relevantPatternIds[0]!],
|
||||
outcomeImproved: true,
|
||||
}));
|
||||
const report = evaluateStructuralRetrieval(corpus, results, { rankerVersion: 'oracle/v1', k: 2 });
|
||||
expect(report.overall).toMatchObject({ support: 100 });
|
||||
expect(report.overall.recallAtK.value).toBe(1);
|
||||
expect(report.overall.hitAt1.value).toBe(1);
|
||||
expect(report.overall.mrr.value).toBe(1);
|
||||
expect(report.overall.ndcg.value).toBe(1);
|
||||
expect(report.overall.top1Margin.value).toBeCloseTo(0.6);
|
||||
expect(report.diagnostics.success).toBe(100);
|
||||
expect(report.relationViolations).toBe(0);
|
||||
expect(Object.values(report.slices).every(slice => slice?.support === 20)).toBe(true);
|
||||
});
|
||||
|
||||
it('acts as a negative control by exposing lexical-overlap ranking failure', () => {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
const report = evaluateStructuralRetrieval(corpus, lexicalResults(), { rankerVersion: 'lexical-overlap/v1' });
|
||||
expect(report.overall.hitAt1.value).toBeLessThan(0.8);
|
||||
expect(report.relationViolations).toBeGreaterThan(0);
|
||||
expect(report.diagnostics.success).toBe(0);
|
||||
});
|
||||
|
||||
it('distinguishes each retrieval-to-outcome failure stage', () => {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
const query = corpus.queries[0]!;
|
||||
const relevant = query.relevantPatternIds[0]!;
|
||||
const wrong = query.mustNotRankAbove[0]!.lowerPatternId;
|
||||
const result = (candidates: QueryRetrievalResult['candidates'], appliedPatternIds?: string[], outcomeImproved?: boolean) =>
|
||||
({ queryId: query.id, candidates, appliedPatternIds, outcomeImproved });
|
||||
expect(diagnoseRetrieval(query, result([{ patternId: wrong, score: 1 }]), 2)).toBe('not_retrieved');
|
||||
expect(diagnoseRetrieval(query, result([{ patternId: wrong, score: 1 }, { patternId: relevant, score: 0.5 }]), 2))
|
||||
.toBe('retrieved_misranked');
|
||||
expect(diagnoseRetrieval(query, result([{ patternId: relevant, score: 1 }]), 2)).toBe('ranked_not_used');
|
||||
expect(diagnoseRetrieval(query, result([{ patternId: relevant, score: 1 }], [relevant], false), 2))
|
||||
.toBe('used_no_benefit');
|
||||
expect(diagnoseRetrieval(query, result([{ patternId: relevant, score: 1 }], [relevant], true), 2)).toBe('success');
|
||||
});
|
||||
|
||||
it('rejects incomplete benchmark runs', () => {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
expect(() => evaluateStructuralRetrieval(corpus, [], { rankerVersion: 'broken/v1' }))
|
||||
.toThrow('exactly one entry for every corpus query');
|
||||
});
|
||||
|
||||
it('rejects attribution to a pattern absent from the candidate evidence', () => {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
const results = corpus.queries.map(query => ({
|
||||
queryId: query.id,
|
||||
candidates: [{ patternId: query.relevantPatternIds[0]!, score: 1 }],
|
||||
appliedPatternIds: [] as string[],
|
||||
}));
|
||||
results[0]!.appliedPatternIds = ['family-20-counterexample'];
|
||||
expect(() => evaluateStructuralRetrieval(corpus, results, { rankerVersion: 'ranker/v1' }))
|
||||
.toThrow('applied pattern was not a candidate');
|
||||
});
|
||||
|
||||
it('rejects tampered corpus lineage and malformed candidate evidence', () => {
|
||||
const corpus = createStructuralRetrievalCorpus();
|
||||
const forged = { ...corpus, corpusRevision: 'forged' };
|
||||
expect(() => evaluateStructuralRetrieval(forged, [], { rankerVersion: 'ranker/v1' }))
|
||||
.toThrow('corpus lineage hash mismatch');
|
||||
|
||||
const results = corpus.queries.map(query => ({
|
||||
queryId: query.id,
|
||||
candidates: [{ patternId: query.relevantPatternIds[0]!, score: 1 }],
|
||||
}));
|
||||
results[0]!.candidates.push({ patternId: results[0]!.candidates[0]!.patternId, score: Number.NaN });
|
||||
expect(() => evaluateStructuralRetrieval(corpus, results, { rankerVersion: 'ranker/v1' }))
|
||||
.toThrow(/finite|duplicate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieval receipts', () => {
|
||||
it('binds representation, embedding, candidates, ranker, use, and outcome without reasoning text', () => {
|
||||
const input = {
|
||||
revision: 'abc123', queryId: 'query-1', queryRepresentationVersion: 'typed/v1',
|
||||
embeddingSpaceId: 'minilm:384:v1', rankerVersion: 'baseline/v1',
|
||||
candidates: [{ patternId: 'pattern-1', score: 0.75 }], appliedPatternIds: ['pattern-1'],
|
||||
outcomeRef: 'run-9', outcomeImproved: true,
|
||||
};
|
||||
const receipt = createRetrievalReceipt(input);
|
||||
expect(receipt.receiptId).toBe(createRetrievalReceipt(input).receiptId);
|
||||
expect(receipt).toMatchObject(input);
|
||||
expect(JSON.stringify(receipt)).not.toMatch(/chain.of.thought|reasoning/i);
|
||||
expect(Object.isFrozen(receipt.candidates)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when provenance is absent', () => {
|
||||
expect(() => createRetrievalReceipt({
|
||||
revision: '', queryId: 'query-1', queryRepresentationVersion: 'typed/v1', embeddingSpaceId: '',
|
||||
rankerVersion: 'ranker/v1', candidates: [],
|
||||
})).toThrow('provenance fields must be non-empty');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user