180 lines
9.1 KiB
TypeScript
180 lines
9.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
groundRegulatoryCandidateAssessmentResult,
|
|
groundRegulatoryMappingResult,
|
|
regulatoryCandidateAssessmentValidationErrors,
|
|
regulatoryMappingResultValidationErrors,
|
|
regulatoryMappingRunValidationErrors,
|
|
selectRegulatoryMappingsFromAssessments,
|
|
type RegulatoryCandidateAssessmentResult,
|
|
type RegulatoryMappingRun
|
|
} from '../../src/domain/regulatory-mapping';
|
|
|
|
const grounded = new Map([['5.1', new Set(['semantic-5.1', 'reference-5.1'])]]);
|
|
|
|
describe('regulatory mapping output validation', () => {
|
|
it('requires one grounded assessment for every supplied candidate', () => {
|
|
const allowed = new Map([
|
|
['5.1', new Set(['semantic-5.1'])],
|
|
['8.2', new Set(['semantic-8.2'])]
|
|
]);
|
|
const valid = {
|
|
schemaVersion: 1, outcome: 'assessed', assessments: [
|
|
{ controlId: '5.1', relevance: 'primary', confidence: 'high', rationale: 'Direct.', evidenceChunkIds: ['semantic-5.1'] },
|
|
{ controlId: '8.2', relevance: 'irrelevant', confidence: 'high', rationale: 'Only broad overlap.', evidenceChunkIds: ['semantic-8.2'] }
|
|
]
|
|
};
|
|
expect(regulatoryCandidateAssessmentValidationErrors(valid, allowed)).toEqual([]);
|
|
expect(regulatoryCandidateAssessmentValidationErrors({ ...valid, assessments: valid.assessments.slice(0, 1) }, allowed))
|
|
.toEqual(expect.arrayContaining([expect.stringContaining('candidate control 8.2'), expect.stringContaining('every supplied candidate')]));
|
|
expect(regulatoryCandidateAssessmentValidationErrors({ ...valid, assessments: [...valid.assessments, valid.assessments[0]] }, allowed))
|
|
.toEqual(expect.arrayContaining([expect.stringContaining('duplicates control 5.1')]));
|
|
});
|
|
|
|
it('grounds assessments and deterministically selects at most one primary plus three supporting controls', () => {
|
|
const allowed = new Map([
|
|
['5.1', new Set(['chunk-5.1'])], ['5.2', new Set(['chunk-5.2'])], ['5.3', new Set(['chunk-5.3'])],
|
|
['5.4', new Set(['chunk-5.4'])], ['5.5', new Set(['chunk-5.5'])]
|
|
]);
|
|
const assessment = (controlId: string, relevance: 'primary' | 'supporting' | 'irrelevant', confidence: 'low' | 'medium' | 'high') => ({
|
|
controlId, relevance, confidence, rationale: `Synthetic ${controlId}.`, evidenceChunkIds: [`chunk-${controlId}`, 'foreign']
|
|
});
|
|
const groundedAssessment = groundRegulatoryCandidateAssessmentResult({
|
|
schemaVersion: 1, outcome: 'assessed', assessments: [
|
|
assessment('5.1', 'primary', 'medium'), assessment('5.2', 'primary', 'high'),
|
|
assessment('5.3', 'supporting', 'high'), assessment('5.4', 'supporting', 'medium'), assessment('5.5', 'supporting', 'low')
|
|
]
|
|
}, allowed) as RegulatoryCandidateAssessmentResult;
|
|
expect(regulatoryCandidateAssessmentValidationErrors(groundedAssessment, allowed)).toEqual([]);
|
|
|
|
const selected = selectRegulatoryMappingsFromAssessments(groundedAssessment, ['5.1', '5.2', '5.3', '5.4', '5.5']);
|
|
expect(selected.mappings.map(item => `${item.controlId}:${item.relationship}`)).toEqual([
|
|
'5.2:primary', '5.1:supporting', '5.3:supporting', '5.4:supporting'
|
|
]);
|
|
expect(selected.mappings.every(item => item.evidenceChunkIds.length === 1)).toBe(true);
|
|
});
|
|
|
|
it('derives supporting-only and no-relevant-control outcomes without forcing a primary', () => {
|
|
const supporting = selectRegulatoryMappingsFromAssessments({
|
|
schemaVersion: 1, outcome: 'assessed', assessments: [{
|
|
controlId: '5.1', relevance: 'supporting', confidence: 'medium', rationale: 'Material support.', evidenceChunkIds: ['semantic-5.1']
|
|
}]
|
|
}, ['5.1']);
|
|
expect(supporting).toMatchObject({ outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'supporting' }] });
|
|
|
|
const irrelevant = selectRegulatoryMappingsFromAssessments({
|
|
schemaVersion: 1, outcome: 'assessed', assessments: [{
|
|
controlId: '5.1', relevance: 'irrelevant', confidence: 'high', rationale: 'Only topic overlap.', evidenceChunkIds: ['semantic-5.1']
|
|
}]
|
|
}, ['5.1']);
|
|
expect(irrelevant).toMatchObject({ outcome: 'no_relevant_control', mappings: [] });
|
|
});
|
|
|
|
it('accepts a grounded proposal and rejects unknown controls, duplicate controls, and foreign evidence', () => {
|
|
const valid = {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{
|
|
controlId: '5.1', relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.',
|
|
evidenceChunkIds: ['semantic-5.1', 'reference-5.1']
|
|
}]
|
|
};
|
|
expect(regulatoryMappingResultValidationErrors(valid, grounded)).toEqual([]);
|
|
expect(regulatoryMappingResultValidationErrors({ ...valid, mappings: [
|
|
...valid.mappings, { ...valid.mappings[0], evidenceChunkIds: ['other'] }
|
|
] }, grounded)).toEqual(expect.arrayContaining([
|
|
expect.stringContaining('duplicates control 5.1'),
|
|
expect.stringContaining('not grounded')
|
|
]));
|
|
expect(regulatoryMappingResultValidationErrors({ ...valid, mappings: [{ ...valid.mappings[0], controlId: '9.9' }] }, grounded))
|
|
.toEqual(expect.arrayContaining([expect.stringContaining('unknown candidate control')]));
|
|
});
|
|
|
|
it('trims ungrounded evidence ids, drops fully ungrounded known-control mappings, and leaves unknown controls for validation', () => {
|
|
const result = groundRegulatoryMappingResult({
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [
|
|
{ controlId: '5.1', relationship: 'primary', confidence: 'high', rationale: 'One good id kept.', evidenceChunkIds: ['semantic-5.1', 'foreign'] },
|
|
{ controlId: '5.1', relationship: 'supporting', confidence: 'low', rationale: 'No real evidence.', evidenceChunkIds: ['foreign', 'other'] }
|
|
]
|
|
}, grounded) as { mappings: Array<{ evidenceChunkIds: string[] }> };
|
|
expect(result.mappings).toHaveLength(1);
|
|
expect(result.mappings[0].evidenceChunkIds).toEqual(['semantic-5.1']);
|
|
|
|
const unknown = groundRegulatoryMappingResult({
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '9.9', relationship: 'primary', confidence: 'high', rationale: 'x', evidenceChunkIds: ['semantic-5.1'] }]
|
|
}, grounded) as { mappings: unknown[] };
|
|
expect(unknown.mappings).toHaveLength(1);
|
|
expect(regulatoryMappingResultValidationErrors(unknown, grounded)).toEqual(expect.arrayContaining([expect.stringContaining('unknown candidate control')]));
|
|
});
|
|
|
|
it('requires refusal outcomes to contain no mappings and a bounded reason', () => {
|
|
expect(regulatoryMappingResultValidationErrors({
|
|
schemaVersion: 1, outcome: 'no_relevant_control', reason: 'No direct relationship.', mappings: []
|
|
}, grounded)).toEqual([]);
|
|
expect(regulatoryMappingResultValidationErrors({
|
|
schemaVersion: 1, outcome: 'insufficient_context', reason: 'Unknown.', mappings: [{
|
|
controlId: '5.1', relationship: 'supporting', confidence: 'low', rationale: 'Synthetic.', evidenceChunkIds: ['semantic-5.1']
|
|
}]
|
|
}, grounded)).toContain('A refusal outcome must not include mappings.');
|
|
});
|
|
|
|
it('accepts a mapped result with supporting controls and no primary control', () => {
|
|
expect(regulatoryMappingResultValidationErrors({
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{
|
|
controlId: '5.1', relationship: 'supporting', confidence: 'medium', rationale: 'Relevant, but not uniquely primary.',
|
|
evidenceChunkIds: ['semantic-5.1']
|
|
}]
|
|
}, grounded)).toEqual([]);
|
|
});
|
|
|
|
it('accepts a twelve-control shortlist and rejects a thirteenth control', () => {
|
|
const candidate = (index: number) => {
|
|
const controlId = `8.${index + 1}`;
|
|
return {
|
|
controlId,
|
|
annexAControlId: `A.${controlId}`,
|
|
title: `Synthetic control ${controlId}`,
|
|
score: 1 - index / 100,
|
|
evidence: [{
|
|
chunkId: `chunk-${controlId}`,
|
|
textSha256: 'a'.repeat(64),
|
|
sourceSha256: 'b'.repeat(64),
|
|
sourceStandard: 'ISO/IEC 27002' as const,
|
|
sectionKind: 'control' as const,
|
|
pageStart: 1,
|
|
pageEnd: 1,
|
|
text: 'Synthetic evidence.'
|
|
}]
|
|
};
|
|
};
|
|
const run: RegulatoryMappingRun = {
|
|
version: 1,
|
|
id: 'mapping-1',
|
|
ruleKey: 'pingcastle:synthetic',
|
|
sourceTool: 'pingcastle',
|
|
status: 'running',
|
|
queryHash: 'c'.repeat(64),
|
|
preparedQuery: {
|
|
schemaVersion: 2,
|
|
query: 'Synthetic full context.',
|
|
retrievalQuery: 'Synthetic concise query.',
|
|
specificity: 'rule',
|
|
publicTopics: []
|
|
},
|
|
corpusId: 'synthetic',
|
|
corpusVersion: '1',
|
|
sourceHashes: ['b'.repeat(64)],
|
|
embeddingModel: 'synthetic',
|
|
retrievalVersion: 'synthetic',
|
|
promptVersion: 'synthetic',
|
|
outputSchemaVersion: 1,
|
|
model: 'synthetic',
|
|
createdAt: '2026-09-17T00:00:00.000Z',
|
|
updatedAt: '2026-09-17T00:00:00.000Z',
|
|
candidates: Array.from({ length: 12 }, (_, index) => candidate(index))
|
|
};
|
|
|
|
expect(regulatoryMappingRunValidationErrors(run)).toEqual([]);
|
|
expect(regulatoryMappingRunValidationErrors({ ...run, candidates: [...run.candidates, candidate(12)] }))
|
|
.toContain('Regulatory mapping candidates must contain one to twelve controls when processing has started.');
|
|
});
|
|
});
|