From 8411f9759e14bce0d86be0a0b7e08db6d862ae2b Mon Sep 17 00:00:00 2001 From: sax3l Date: Mon, 21 Sep 2026 13:07:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(X-737):=20Synthetic=20Data=20Factory=20?= =?UTF-8?q?=E2=80=94=20sampling=E2=86=92synthesis=20(INF0-port)=E2=86=92PI?= =?UTF-8?q?I/privacy=20(fail-closed=20f=C3=B6re=20publish)=E2=86=92quality?= =?UTF-8?q?=E2=86=92dedupe=E2=86=92contamination=E2=86=92deterministisk=20?= =?UTF-8?q?train/val/test-split;=20provenance=20per=20rad=20(policy+model+?= =?UTF-8?q?source+sha256).=205/5=20tester?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/synthetic/factory.ts | 206 ++++++++++++++++++++++++++++++++ tests/synthetic-factory.test.ts | 157 ++++++++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 src/synthetic/factory.ts create mode 100644 tests/synthetic-factory.test.ts diff --git a/src/synthetic/factory.ts b/src/synthetic/factory.ts new file mode 100644 index 0000000..f2828e7 --- /dev/null +++ b/src/synthetic/factory.ts @@ -0,0 +1,206 @@ +/** + * `0pt/synthetic/factory.ts` — X-737: Synthetic Data Factory. + * + * Ägande enligt kontraktet: 0pt äger dataset-truth + transformationer; + * INF0 äger generering (injectas via port). Pipeline: + * + * source DatasetVersion + * → sampling + * → synthesis (INF0 GenerationPort) + * → PII/privacy-check (fail-closed FÖRE publish) + * → quality-filter + * → dedupe + * → contamination-detection (rader = identiska med val/test-kandidater) + * → train/val/test-split + * → ny DatasetVersion (0pt-repot) + * + * PROVENANS PER RAD (acceptans): varje syntetisk rad bär generation-policy-ref + * + model-version + source-ref. Privacy-check blockerar publish. + */ + +import { createHash } from 'node:crypto'; + +export type PiiClass = 'none' | 'low' | 'medium' | 'high'; + +export interface SourceRow { + id: string; + fields: Record; +} + +export interface GenerationConfig { + /** Policy-ref för genereringen (CL0UD/0pt-policy — spärras per rad). */ + generationPolicyRef: string; + /** INF0-modellversion som genererade (provener per rad). */ + modelVersion: string; + /** Antal rader som ska syntetiseras. */ + count: number; + /** Högst tillåten PII-klass i publicerad data (över → NEKAS). */ + maxAllowedPiiClass: PiiClass; + /** Quality-tröskel per rad (0–1). */ + minQualityScore: number; + /** Train/val/test-fördelning (default 70/15/15). */ + split?: { train: number; val: number; test: number }; +} + +export interface GeneratedRow { + fields: Record; + /** Kvalitetspoäng från genereringen (0–1). */ + qualityScore: number; + provenance: SyntheticRowProvenance; +} + +export interface SyntheticRowProvenance { + rowId: string; + generationPolicyRef: string; + modelVersion: string; + sourceDatasetRef: string; + sourceRowIds: string[]; + sha256: string; +} + +/** INF0:s genereringsport (0pt anropar — INF0 äger modellen). */ +export interface GenerationPort { + generate(input: { + sourceRows: SourceRow[]; + count: number; + policyRef: string; + }): Promise<{ rows: GeneratedRow[] }>; +} + +/** Privacy-port (X-728-presidio i INF0/integrations): klassificera rader. */ +export interface PrivacyCheckPort { + classifyRow(fields: Record): Promise; +} + +const PII_ORDER: Record = { none: 0, low: 1, medium: 2, high: 3 }; + +export interface PipelineStageReport { + stage: 'sampled' | 'synthesized' | 'privacy' | 'quality' | 'dedupe' | 'contamination' | 'split'; + inputCount: number; + outputCount: number; +} + +export interface SyntheticDatasetVersionDraft { + sourceDatasetId: string; + sourceVersion: number; + tenantId: string; + projectId: string; + schemaVersion: string; + description: string; + rows: Array<{ split: 'train' | 'val' | 'test'; provenance: SyntheticRowProvenance; fields: Record }>; + stages: PipelineStageReport[]; + /** Privacy-checks före publish — bevis för acceptansen. */ + privacyChecks: { checked: number; blocked: number }; + recordCount: number; + datasetDraft: { + dataset_id: string; + tenant_id: string; + project_id: string; + schema_version: string; + artifact_refs: string[]; + description: string; + record_count: number; + created_by: string; + }; +} + +export class SyntheticDataFactory { + constructor( + private readonly generation: GenerationPort, + private readonly privacy: PrivacyCheckPort, + ) {} + + /** Kör hela kedjan; nekar publish om privacy/quality står sig ej. */ + async run(input: { + sourceDatasetId: string; + sourceVersion: number; + tenantId: string; + projectId: string; + sourceRows: SourceRow[]; + evalRowSignatures?: readonly string[]; + config: GenerationConfig; + createdBy: string; + }): Promise { + const stages: PipelineStageReport[] = []; + const cfg = input.config; + + // 1. sampling + const sampled = input.sourceRows.slice(0, Math.max(1, Math.ceil(input.sourceRows.length / 2))); + stages.push({ stage: 'sampled', inputCount: input.sourceRows.length, outputCount: sampled.length }); + + // 2. syntes (INF0) + const { rows } = await this.generation.generate({ + sourceRows: sampled, + count: cfg.count, + policyRef: cfg.generationPolicyRef, + }); + stages.push({ stage: 'synthesized', inputCount: sampled.length, outputCount: rows.length }); + + // 3. PII/privacy — fail-closed FÖRE publish + const privacyChecks = { checked: 0, blocked: 0 }; + const privacyOk: GeneratedRow[] = []; + for (const row of rows) { + privacyChecks.checked += 1; + const cls = await this.privacy.classifyRow(row.fields); + if (PII_ORDER[cls] > PII_ORDER[cfg.maxAllowedPiiClass]) { + privacyChecks.blocked += 1; + continue; + } + privacyOk.push(row); + } + stages.push({ stage: 'privacy', inputCount: rows.length, outputCount: privacyOk.length }); + + // 4. quality-filter + const qualityOk = privacyOk.filter((r) => r.qualityScore >= cfg.minQualityScore); + stages.push({ stage: 'quality', inputCount: privacyOk.length, outputCount: qualityOk.length }); + + // 5. dedupe (sha256 av fält) + const seen = new Set(); + const deduped: GeneratedRow[] = []; + for (const r of qualityOk) { + const h = createHash('sha256').update(JSON.stringify(r.fields)).digest('hex'); + if (seen.has(h)) continue; + seen.add(h); + deduped.push({ ...r, provenance: { ...r.provenance, sha256: h } }); + } + stages.push({ stage: 'dedupe', inputCount: qualityOk.length, outputCount: deduped.length }); + + // 6. contamination — rader identiska med val/test-eval-signaturer raderas + const evalSigs = new Set(input.evalRowSignatures ?? []); + const clean = deduped.filter((r) => !evalSigs.has(r.provenance.sha256)); + stages.push({ stage: 'contamination', inputCount: deduped.length, outputCount: clean.length }); + + // 7. train/val/test-split (deterministiskt via hash) + const split = cfg.split ?? { train: 0.7, val: 0.15, test: 0.15 }; + const outRows: SyntheticDatasetVersionDraft['rows'] = clean.map((r) => { + const frac = parseInt(r.provenance.sha256.slice(0, 8), 16) / 0xffffffff; + const s: 'train' | 'val' | 'test' = frac < split.train ? 'train' : frac < split.train + split.val ? 'val' : 'test'; + return { split: s, provenance: r.provenance, fields: r.fields }; + }); + stages.push({ stage: 'split', inputCount: clean.length, outputCount: outRows.length }); + + const datasetId = `ds-synthetic-${input.sourceDatasetId}`; + return { + sourceDatasetId: input.sourceDatasetId, + sourceVersion: input.sourceVersion, + tenantId: input.tenantId, + projectId: input.projectId, + schemaVersion: 'synthetic/v1', + description: `Synthetic Data Factory: ${input.sourceDatasetId}@${input.sourceVersion} (policy ${cfg.generationPolicyRef}, modell ${cfg.modelVersion})`, + rows: outRows, + stages, + privacyChecks, + recordCount: outRows.length, + datasetDraft: { + dataset_id: datasetId, + tenant_id: input.tenantId, + project_id: input.projectId, + schema_version: 'synthetic/v1', + artifact_refs: [`st0re://synthetic/${input.sourceDatasetId}/v${input.sourceVersion}`], + description: `Synthetic (källa ${input.sourceDatasetId}@${input.sourceVersion}, policy ${cfg.generationPolicyRef})`, + record_count: outRows.length, + created_by: input.createdBy, + }, + }; + } +} diff --git a/tests/synthetic-factory.test.ts b/tests/synthetic-factory.test.ts new file mode 100644 index 0000000..89fdf42 --- /dev/null +++ b/tests/synthetic-factory.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SyntheticDataFactory, type GenerationConfig, type GenerationPort, type PrivacyCheckPort, type SourceRow } from '../src/synthetic/factory'; + +function sourceRows(n: number): SourceRow[] { + return Array.from({ length: n }, (_, i) => ({ id: `src-${i}`, fields: { fönster: i, kundtyp: i % 2 ? 'B2B' : 'B2C' } })); +} + +function makeFactory(opts: { piiPerRow?: Record[0] extends never ? never : string> } = {}): { + factory: SyntheticDataFactory; + generation: GenerationPort; + privacy: PrivacyCheckPort; +} { + const generated = Array.from({ length: 12 }, (_, i) => ({ + fields: { rad: i, frukt: 'apelsin' }, + qualityScore: i < 9 ? 0.9 : 0.3, // 3 rader under quality-tröskeln + provenance: { + rowId: `syn-${i}`, + generationPolicyRef: 'cl0ud-policy-syn-1', + modelVersion: 'inf0-synthesizer@2.1', + sourceDatasetRef: 'ds-kunder@3', + sourceRowIds: ['src-1', 'src-2'], + sha256: '', + }, + })); + const generation: GenerationPort = { + generate: vi.fn(async (input) => ({ rows: generated.map((r) => ({ ...r, provenance: { ...r.provenance, sourceDatasetRef: `ds-kunder@3` } })) })), + }; + const privacy: PrivacyCheckPort = { + classifyRow: vi.fn(async (fields: Record) => { + const rad = Number((fields as { rad: number }).rad); + // rad 0 = PII high (blockas vid maxAllowed=medium), rad 1 = medium + if (rad === 0) return 'high'; + if (rad === 1) return 'medium'; + return 'none'; + }), + }; + return { factory: new SyntheticDataFactory(generation, privacy), generation, privacy }; +} + +const CFG: GenerationConfig = { + generationPolicyRef: 'cl0ud-policy-syn-1', + modelVersion: 'inf0-synthesizer@2.1', + count: 12, + maxAllowedPiiClass: 'medium', + minQualityScore: 0.8, +}; + +describe('X-737: hela pipelinen', () => { + it('sampling→synthesis→privacy→quality→dedupe→contamination→split med steg-rapport', async () => { + const { factory: synFactory } = makeFactory(); + const draft = await synFactory.run({ + sourceDatasetId: 'ds-kunder', + sourceVersion: 3, + tenantId: 't_0pt', + projectId: 'p-1', + sourceRows: sourceRows(10), + config: CFG, + createdBy: '0pt-synthetic', + }); + expect(draft.stages.map((s: { stage: string }) => s.stage)).toEqual(['sampled', 'synthesized', 'privacy', 'quality', 'dedupe', 'contamination', 'split']); + // 12 syntetiska: 1 PII-blockad + 3 quality-bort → 8 kvar + expect(draft.recordCount).toBe(8); + expect(draft.privacyChecks).toEqual({ checked: 12, blocked: 1 }); + }); + + it('contamination: rader identiska med eval-signaturer raderas', async () => { + const { factory: synFactory } = makeFactory(); + // räkna ut sha för rad 2:s fält (apelsin-rad) och stoppa i eval-signaturer + const { createHash } = await import('node:crypto'); + const sig = createHash('sha256').update(JSON.stringify({ rad: 2, frukt: 'apelsin' })).digest('hex'); + const draft = await synFactory.run({ + sourceDatasetId: 'ds-kunder', + sourceVersion: 3, + tenantId: 't_0pt', + projectId: 'p-1', + sourceRows: sourceRows(10), + evalRowSignatures: [sig], + config: CFG, + createdBy: '0pt-synthetic', + }); + expect(draft.recordCount).toBe(7); // 8 − 1 kontaminerad + expect(draft.stages.find((s) => s.stage === 'contamination')?.outputCount).toBe(7); + }); +}); + +describe('X-737 acceptans: varje rad har provenance', () => { + it('generation-policy + model-version + source-ref på VARJE rad', async () => { + const { factory: synFactory } = makeFactory(); + const draft = await synFactory.run({ + sourceDatasetId: 'ds-kunder', + sourceVersion: 3, + tenantId: 't_0pt', + projectId: 'p-1', + sourceRows: sourceRows(10), + config: CFG, + createdBy: '0pt-synthetic', + }); + expect(draft.rows.length).toBeGreaterThan(0); + for (const row of draft.rows) { + expect(row.provenance.generationPolicyRef).toBe('cl0ud-policy-syn-1'); + expect(row.provenance.modelVersion).toBe('inf0-synthesizer@2.1'); + expect(row.provenance.sourceDatasetRef).toBe('ds-kunder@3'); + expect(row.provenance.sha256).toMatch(/^[0-9a-f]{64}$/); + } + }); + + it('privacy-check FÖRE publish: blockade rader är ALDRIG i draften', async () => { + const { factory: synFactory, generation } = makeFactory(); + const draft = await synFactory.run({ + sourceDatasetId: 'ds-kunder', + sourceVersion: 3, + tenantId: 't_0pt', + projectId: 'p-1', + sourceRows: sourceRows(10), + config: CFG, + createdBy: '0pt-synthetic', + }); + expect(generation.generate).toHaveBeenCalled(); + // rad 0 (PII high) är borta trots att genereringen producerade den + expect(draft.rows.find((r: { provenance: { rowId: string } }) => r.provenance.rowId === 'syn-0')).toBeUndefined(); + // dataset-draften är CreateDatasetVersionInput-kompatibel + expect(draft.datasetDraft.dataset_id).toContain('ds-kunder'); + expect(draft.datasetDraft.record_count).toBe(draft.recordCount); + }); + + it('train/val/test-split är deterministiskt och täcker alla rader', async () => { + const { factory: synFactory } = makeFactory(); + const draft = await synFactory.run({ + sourceDatasetId: 'ds-kunder', + sourceVersion: 3, + tenantId: 't_0pt', + projectId: 'p-1', + sourceRows: sourceRows(10), + config: CFG, + createdBy: '0pt-synthetic', + }); + const splits = draft.rows.map((r) => r.split); + for (const s of splits) { + expect(['train', 'val', 'test']).toContain(s); + } + // determinism: samma körning igen → samma split per provenance-rad + const draft2 = await synFactory.run({ + sourceDatasetId: 'ds-kunder', + sourceVersion: 3, + tenantId: 't_0pt', + projectId: 'p-1', + sourceRows: sourceRows(10), + config: CFG, + createdBy: '0pt-synthetic', + }); + const byId = new Map(draft2.rows.map((r) => [r.provenance.rowId, r.split])); + for (const r of draft.rows) { + expect(byId.get(r.provenance.rowId)).toBe(r.split); + } + }); +}); -- 2.54.0