From 05603c6e71ab9cfc6de4f5e7f8f5adaa5618b0b5 Mon Sep 17 00:00:00 2001 From: Selim Date: Fri, 10 Jul 2026 08:06:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(vehicle):=20ara=C3=A7=20=C3=B6=C4=9Frenme?= =?UTF-8?q?=20motoru=20temeli=20(P2-1,=20foundation-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VKB (araç-başına PID/DID) + Manufacturer Intelligence çıktısını SALT-OKUNUR tüketip marka/protokol/ECU bazlı öğrenme kanıtı (Evidence) üretir. Yalnız foundation: KALICILIK YOK · BOOT WIRING YOK · SQL/Cloud/LLM/Native YOK · decay YOK. - vehicleLearningEngine.ts (yeni, saf on-demand): * LearningEvidence: evidenceId(manufacturer|protocol|source|pidOrDid|mode) + manufacturer/ profileHint/protocol/discoverySource/pidOrDid/mode/ecuAddresses/supportingVehicleHashes/ vehicleCount/observationCount/firstSeen/lastSeen/confidence/status/createdAt/updatedAt * buildEvidenceFromRecords: deterministik tekilleştirme — aynı araç(fingerprintHash) tekrar → vehicleCount ARTMAZ (Set), observationCount artar, firstSeen korunur, lastSeen güncellenir; ECU normalize/sort/unique; hash normalize/sort/unique * evidenceConfidence/evidenceStatus (iskelet, decay YOK): 1 araç weak · ≥2 candidate · ≥3 veya ≥2araç+≥2ECU strong; tek-araç-çok-tekrar confidence ŞİŞMEZ; [0,1] clamp * VehicleLearningEngine: on-demand computeEvidence/getStrong/getCandidates, fail-soft Mevcut hiçbir katman değişmedi (2 yeni dosya) — salt-okunur türetme, girdi mutate edilmez. Dokunulmadı: OBD native · poll · Discovery Capture/Queue · Fingerprint · Auto Learning · VKB · Manufacturer Intelligence · Profile Builder · Diagnostic Engine · PID/DID registry · SQL/Supabase. 14 test. tsc -b temiz · eslint temiz · 2507 test yeşil (23 bilinen env-gated, ilgisiz). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Fauew13VP3vbfKqnocPdfX --- src/__tests__/vehicleLearningEngine.test.ts | 177 +++++++++++++ src/platform/vehicleLearningEngine.ts | 263 ++++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 src/__tests__/vehicleLearningEngine.test.ts create mode 100644 src/platform/vehicleLearningEngine.ts diff --git a/src/__tests__/vehicleLearningEngine.test.ts b/src/__tests__/vehicleLearningEngine.test.ts new file mode 100644 index 00000000..972114bb --- /dev/null +++ b/src/__tests__/vehicleLearningEngine.test.ts @@ -0,0 +1,177 @@ +/** + * vehicleLearningEngine.test.ts — Araç Öğrenme Motoru TEMELİ (P2-1). + * + * Kilitlenen davranışlar: marka bazlı PID/DID evidence · farklı markalar karışmaz · aynı araç + * tekrar → vehicleCount sabit / observationCount↑ · ECU normalize · hash tekilleştirme · + * firstSeen korunur · lastSeen güncellenir · weak/candidate/strong · confidence clamp · + * boş/bozuk input fail-soft · girdi mutate edilmez. + */ +import { describe, it, expect } from 'vitest'; +import { + buildEvidenceFromRecords, + evidenceConfidence, + evidenceStatus, + VehicleLearningEngine, +} from '../platform/vehicleLearningEngine'; +import { type VehicleKnowledgeRecord } from '../platform/vehicleKnowledgeBase'; +import { type DiscoveredSignal } from '../platform/autoLearningEngine'; + +let _i = 0; +function sig(firstSeen: number, lastSeen: number, seenCount: number): DiscoveredSignal { + return { firstSeen, lastSeen, seenCount, confidence: 0.5 }; +} +function rec(over: { + hash?: string; vin?: string; profileHint?: string; protocol?: string; + ecus?: string[]; pids?: Record; dids?: Record; + firstSeen?: number; lastSeen?: number; +} = {}): VehicleKnowledgeRecord { + return { + fingerprintHash: over.hash ?? `h-${_i++}`, + vehicleSignature: '6::7E8', + vin: over.vin ?? '', + profileHint: over.profileHint ?? '', + protocol: over.protocol ?? '6', + discoveredPids: over.pids ?? {}, + discoveredDids: over.dids ?? {}, + discoveredEcus: over.ecus ?? ['7E8'], + firstSeen: over.firstSeen ?? 1000, + lastSeen: over.lastSeen ?? 1000, + totalConnections: 1, totalDiscoveries: 0, confidence: 0.5, firmwareVersions: [], supportedModes: [], + }; +} + +/* ── confidence / status ──────────────────────────────────────────────────── */ +describe('evidenceConfidence / evidenceStatus', () => { + it('confidence [0,1] + tek araç çok tekrar ŞİŞMEZ', () => { + expect(evidenceConfidence(1, 100, 2)).toBeLessThanOrEqual(0.5); // tek araç → tavan düşük + expect(evidenceConfidence(3, 5, 2)).toBeGreaterThan(evidenceConfidence(1, 5, 2)); + expect(evidenceConfidence(99, 99, 99)).toBeLessThanOrEqual(1); + }); + it('status: 1 araç weak · 2+2ECU strong · 3 araç strong · 2+1ECU candidate', () => { + expect(evidenceStatus(1, 3)).toBe('weak'); + expect(evidenceStatus(2, 2)).toBe('strong'); + expect(evidenceStatus(3, 1)).toBe('strong'); + expect(evidenceStatus(2, 1)).toBe('candidate'); + }); +}); + +/* ── evidence üretimi ─────────────────────────────────────────────────────── */ +describe('buildEvidenceFromRecords', () => { + it('aynı marka PID evidence\'i oluşuyor (vehicleCount + evidenceId)', () => { + const out = buildEvidenceFromRecords([ + rec({ profileHint: 'Renault', hash: 'v1', pids: { A5: sig(1000, 2000, 2) } }), + rec({ profileHint: 'Renault', hash: 'v2', pids: { A5: sig(1500, 3000, 3) }, ecus: ['7E9'] }), + ], { now: 5000 }); + expect(out).toHaveLength(1); + const e = out[0]; + expect(e.evidenceId).toBe('Renault|6|PID|A5|01'); + expect(e.manufacturer).toBe('Renault'); + expect(e.discoverySource).toBe('PID'); + expect(e.mode).toBe('01'); + expect(e.vehicleCount).toBe(2); + expect(e.observationCount).toBe(5); // 2 + 3 + expect(e.ecuAddresses).toEqual(['7E8', '7E9']); // iki araç ECU birleşti + expect(e.supportingVehicleHashes).toEqual(['v1', 'v2']); + expect(e.firstSeen).toBe(1000); + expect(e.lastSeen).toBe(3000); + expect(e.status).toBe('strong'); // 2 araç + 2 ECU + expect(e.createdAt).toBe(5000); + }); + + it('aynı marka DID evidence\'i (mode 22)', () => { + const out = buildEvidenceFromRecords([ + rec({ profileHint: 'Renault', hash: 'v1', dids: { F190: sig(1, 1, 1) } }), + ]); + expect(out[0].discoverySource).toBe('DID'); + expect(out[0].mode).toBe('22'); + expect(out[0].evidenceId).toBe('Renault|6|DID|F190|22'); + }); + + it('farklı markalar KARIŞMIYOR', () => { + const out = buildEvidenceFromRecords([ + rec({ profileHint: 'Renault', hash: 'v1', pids: { A5: sig(1, 1, 1) } }), + rec({ profileHint: 'Ford', hash: 'v2', pids: { A5: sig(1, 1, 1) } }), + ]); + expect(out).toHaveLength(2); + expect(out.map((e) => e.manufacturer).sort()).toEqual(['Ford', 'Renault']); + for (const e of out) expect(e.vehicleCount).toBe(1); + }); + + it('aynı araç tekrar görünce vehicleCount ARTMAZ; observationCount artar; firstSeen korunur; lastSeen güncellenir', () => { + const out = buildEvidenceFromRecords([ + rec({ profileHint: 'Renault', hash: 'v1', pids: { A5: sig(1000, 2000, 2) } }), + rec({ profileHint: 'Renault', hash: 'v1', pids: { A5: sig(500, 4000, 3) } }), // AYNI hash + ]); + expect(out).toHaveLength(1); + expect(out[0].vehicleCount).toBe(1); // artmadı + expect(out[0].supportingVehicleHashes).toEqual(['v1']); // tekilleştirildi + expect(out[0].observationCount).toBe(5); // 2 + 3 + expect(out[0].firstSeen).toBe(500); // min korundu + expect(out[0].lastSeen).toBe(4000); // max güncellendi + }); + + it('ECU adresleri normalize ediliyor (boşluk/küçük/0x)', () => { + const out = buildEvidenceFromRecords([ + rec({ profileHint: 'Renault', hash: 'v1', pids: { A5: sig(1, 1, 1) }, ecus: [' 7e8 ', '0x7E9', '7E8'] }), + ]); + expect(out[0].ecuAddresses).toEqual(['7E8', '7E9']); + }); + + it('boş / null / undefined input → [] (fail-soft)', () => { + expect(buildEvidenceFromRecords([])).toEqual([]); + expect(buildEvidenceFromRecords(null)).toEqual([]); + expect(buildEvidenceFromRecords(undefined)).toEqual([]); + }); + + it('bozuk kayıt atlanır, geçerli işlenir (fail-soft)', () => { + const recs = [null, { foo: 'bar' }, rec({ profileHint: 'Renault', hash: 'v1', pids: { A5: sig(1, 1, 1) } })] as unknown as VehicleKnowledgeRecord[]; + const out = buildEvidenceFromRecords(recs); + expect(out).toHaveLength(1); + expect(out[0].manufacturer).toBe('Renault'); + }); + + it('girdi kayıtları MUTATE EDİLMİYOR', () => { + const input = [rec({ profileHint: 'Renault', hash: 'v1', pids: { A5: sig(1000, 2000, 2) }, ecus: ['7E8', '7E9'] })]; + const snap = JSON.parse(JSON.stringify(input)); + buildEvidenceFromRecords(input); + expect(input).toEqual(snap); + }); + + it('VIN WMI ile marka çözümleniyor (profileHint boş olsa da)', () => { + const out = buildEvidenceFromRecords([ + rec({ vin: 'VF1BM0A0H12345678', hash: 'v1', pids: { A5: sig(1, 1, 1) } }), + ]); + expect(out[0].manufacturer).toBe('Renault'); + }); +}); + +/* ── Motor ────────────────────────────────────────────────────────────────── */ +describe('VehicleLearningEngine', () => { + it('computeEvidence VKB + MIE okuyarak üretir; strong/candidate ayrımı', () => { + const records = [ + rec({ profileHint: 'Renault', hash: 'v1', pids: { A5: sig(1, 1, 1) }, ecus: ['7E8'] }), + rec({ profileHint: 'Renault', hash: 'v2', pids: { A5: sig(1, 2, 1) }, ecus: ['7E9'] }), // A5 → 2 araç+2 ECU strong + rec({ profileHint: 'Ford', hash: 'v3', pids: { B0: sig(1, 1, 1) } }), // tek araç weak + ]; + const eng = new VehicleLearningEngine(() => records, () => [], () => 9000); + const all = eng.computeEvidence(); + expect(all.length).toBe(2); // Renault/A5 + Ford/B0 + expect(eng.getStrong().map((e) => e.manufacturer)).toEqual(['Renault']); + expect(eng.getByManufacturer('ford')[0].status).toBe('weak'); + }); + + it('FAIL-SOFT: reader hata fırlatsa da [] döner, çökmez', () => { + const eng = new VehicleLearningEngine(() => { throw new Error('boom'); }); + expect(() => eng.computeEvidence()).not.toThrow(); + expect(eng.computeEvidence()).toEqual([]); + }); + + it('MIE profileHint zenginleştirmesi (kayıt profileHint boşsa)', () => { + // profileHint boş, VIN yok → resolveManufacturer 'Unknown'; MIE hint eşleşmez → boş kalır (güvenli). + const records = [rec({ hash: 'v1', protocol: '6', pids: { A5: sig(1, 1, 1) } })]; + const eng = new VehicleLearningEngine(() => records, () => [], () => 1); + const out = eng.computeEvidence(); + expect(out[0].manufacturer).toBe('Unknown'); + expect(out[0].profileHint).toBe(''); + }); +}); diff --git a/src/platform/vehicleLearningEngine.ts b/src/platform/vehicleLearningEngine.ts new file mode 100644 index 00000000..6c3ab667 --- /dev/null +++ b/src/platform/vehicleLearningEngine.ts @@ -0,0 +1,263 @@ +/** + * vehicleLearningEngine — Araç Öğrenme Motoru TEMELİ (P2-1, foundation-only). + * + * AMAÇ: Vehicle Knowledge Base (araç-başına öğrenilmiş PID/DID) ve Manufacturer Intelligence + * çıktısını SALT-OKUNUR tüketip marka/protokol/ECU bazlı "öğrenme kanıtı" (Evidence) üretir. + * "PID 7A · Renault · 12 araç · 34 gözlem · 8 ECU · confidence 0.91" türü kanıtın çekirdeği. + * + * BU PR (yalnız foundation): SAF, on-demand türetme. + * - KALICILIK YOK · BOOT WIRING YOK · SQL/Cloud/LLM/Native YOK · decay YOK. + * - Mevcut hiçbir katmanı (Discovery/Fingerprint/Auto Learning/VKB/Manufacturer Intelligence/ + * Profile Builder/Diagnostic) DEĞİŞTİRMEZ — yalnız çıktılarını okur. + * - Girdi kayıtlarını MUTASYONA UĞRATMAZ · yeni bağımlılık/ağ YOK · hot-path'e girmez. + * + * KATMAN (Clean Architecture): saf `buildEvidenceFromRecords` view-model'dir (React/native yok); + * `VehicleLearningEngine` yalnız ince on-demand okuyucu sarmalayıcı. FAIL-SOFT. + */ + +import { normalizeEcuAddresses } from './vehicleFingerprintService'; +import { + resolveManufacturer, + getManufacturerIntelligence, + type ManufacturerKnowledge, +} from './manufacturerIntelligenceEngine'; +import { + vehicleKnowledgeBaseStore, + type VehicleKnowledgeRecord, +} from './vehicleKnowledgeBase'; +import { type DiscoveredSignal } from './autoLearningEngine'; + +/* ══════════════════════════════════════════════════════════════════════════ + * Evidence modeli + * ════════════════════════════════════════════════════════════════════════ */ + +export type EvidenceStatus = 'weak' | 'candidate' | 'strong'; + +/** Tek bir öğrenme kanıtı — (marka × protokol × kaynak × pid/did × mode) birimi. */ +export interface LearningEvidence { + /** Deterministik kimlik: manufacturer|protocol|discoverySource|pidOrDid|mode. */ + evidenceId: string; + manufacturer: string; + profileHint: string; + protocol: string; + discoverySource: 'PID' | 'DID'; + pidOrDid: string; + mode: string; + /** Bu sinyalin görüldüğü ECU adresleri (normalize + sıralı + tekil; araç-seviyesi). */ + ecuAddresses: string[]; + /** Kanıtı destekleyen FARKLI araç fingerprint hash'leri (normalize + sıralı + tekil). */ + supportingVehicleHashes: string[]; + /** Farklı araç sayısı (= supportingVehicleHashes.length). */ + vehicleCount: number; + /** Toplam gözlem (tüm araçlardaki seenCount toplamı). */ + observationCount: number; + firstSeen: number; + lastSeen: number; + confidence: number; + status: EvidenceStatus; + createdAt: number; + updatedAt: number; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Deterministik confidence / status (decay YOK — foundation) + * ════════════════════════════════════════════════════════════════════════ */ + +function clamp01(v: number): number { return Math.max(0, Math.min(1, v)); } + +/** + * İskelet confidence — deterministik: taban 0.3; her ek araç +0.2 (max 3 ek); ECU çeşitliliği + * +0.05 (max 2); gözlem +0.02 (max 5 → tavan +0.1). Tek araç çok tekrar etse bile şişmez + * (araç bonusu 0 → max 0.4). [0,1] clamp. + */ +export function evidenceConfidence(vehicleCount: number, observationCount: number, ecuCount: number): number { + const v = Math.max(0, vehicleCount); + return clamp01( + 0.3 + + 0.2 * Math.min(Math.max(v - 1, 0), 3) + + 0.05 * Math.min(Math.max(ecuCount, 0), 2) + + 0.02 * Math.min(Math.max(observationCount, 0), 5), + ); +} + +/** İskelet status: tek araç weak · ≥3 araç veya (≥2 araç ve ≥2 ECU) strong · aksi candidate. */ +export function evidenceStatus(vehicleCount: number, ecuCount: number): EvidenceStatus { + if (vehicleCount <= 1) return 'weak'; + if (vehicleCount >= 3 || (vehicleCount >= 2 && ecuCount >= 2)) return 'strong'; + return 'candidate'; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Kanıt kimliği + iç birikimci + * ════════════════════════════════════════════════════════════════════════ */ + +function _normId(s: string): string { + return (s ?? '').replace(/\s+/g, '').toUpperCase(); +} + +function _evidenceKey(p: { + manufacturer: string; protocol: string; discoverySource: string; pidOrDid: string; mode: string; +}): string { + return `${p.manufacturer}|${p.protocol}|${p.discoverySource}|${p.pidOrDid}|${p.mode}`; +} + +interface _Acc { + manufacturer: string; + profileHint: string; + protocol: string; + discoverySource: 'PID' | 'DID'; + pidOrDid: string; + mode: string; + vehicles: Set; // duplicate suppression + distinct vehicleCount + ecus: Set; + observationCount: number; + firstSeen: number; + lastSeen: number; +} + +export interface BuildEvidenceOptions { + now?: number; + /** MIE çıktısından manufacturer→profileHint zenginleştirme (kayıt profileHint boşsa). */ + manufacturerProfileHints?: Record; +} + +/** + * VKB kayıtlarından öğrenme kanıtları üretir (SAF, salt-okunur). Girdiyi MUTASYONA UĞRATMAZ. + * Boş liste → []; bozuk kayıt atlanır (fail-soft); ASLA throw etmez. + * + * Deterministik: aynı araç (fingerprintHash) aynı sinyali tekrar gösterse vehicleCount ARTMAZ + * (Set), observationCount artar, firstSeen korunur, lastSeen güncellenir. + */ +export function buildEvidenceFromRecords( + records: readonly VehicleKnowledgeRecord[] | null | undefined, + opts: BuildEvidenceOptions = {}, +): LearningEvidence[] { + const now = opts.now ?? Date.now(); + const hints = opts.manufacturerProfileHints ?? {}; + const acc = new Map(); + + for (const r of records ?? []) { + try { + if (!r || typeof r !== 'object' || typeof r.fingerprintHash !== 'string' || !r.fingerprintHash) continue; + const { manufacturer, profileHint } = resolveManufacturer(r); + const resolvedHint = profileHint || hints[manufacturer.toLowerCase()] || ''; + const protocol = (r.protocol ?? '').toUpperCase(); + const recEcus = normalizeEcuAddresses(r.discoveredEcus ?? []); + const hash = r.fingerprintHash; + + const sources: Array<{ src: 'PID' | 'DID'; mode: string; map: Record }> = [ + { src: 'PID', mode: '01', map: r.discoveredPids ?? {} }, + { src: 'DID', mode: '22', map: r.discoveredDids ?? {} }, + ]; + + for (const { src, mode, map } of sources) { + for (const rawId in map) { + const sig = map[rawId]; + if (!sig) continue; + const pidOrDid = _normId(rawId); + if (!pidOrDid) continue; + const key = _evidenceKey({ manufacturer, protocol, discoverySource: src, pidOrDid, mode }); + let a = acc.get(key); + if (!a) { + a = { + manufacturer, profileHint: resolvedHint, protocol, discoverySource: src, pidOrDid, mode, + vehicles: new Set(), ecus: new Set(), + observationCount: 0, firstSeen: sig.firstSeen ?? now, lastSeen: sig.lastSeen ?? now, + }; + acc.set(key, a); + } + if (!a.profileHint && resolvedHint) a.profileHint = resolvedHint; + a.vehicles.add(hash); // distinct — tekrar araç saymaz + for (const e of recEcus) a.ecus.add(e); + a.observationCount += Math.max(0, sig.seenCount ?? 0); + a.firstSeen = Math.min(a.firstSeen, sig.firstSeen ?? a.firstSeen); // korunur + a.lastSeen = Math.max(a.lastSeen, sig.lastSeen ?? a.lastSeen); // güncellenir + } + } + } catch { + /* tek kayıt hatası diğerlerini etkilemez (fail-soft) */ + } + } + + const out: LearningEvidence[] = []; + for (const a of acc.values()) { + const ecuAddresses = [...a.ecus].sort(); + const supportingVehicleHashes = [...a.vehicles].sort(); + const vehicleCount = supportingVehicleHashes.length; + out.push({ + evidenceId: _evidenceKey(a), + manufacturer: a.manufacturer, + profileHint: a.profileHint, + protocol: a.protocol, + discoverySource: a.discoverySource, + pidOrDid: a.pidOrDid, + mode: a.mode, + ecuAddresses, + supportingVehicleHashes, + vehicleCount, + observationCount: a.observationCount, + firstSeen: a.firstSeen, + lastSeen: a.lastSeen, + confidence: evidenceConfidence(vehicleCount, a.observationCount, ecuAddresses.length), + status: evidenceStatus(vehicleCount, ecuAddresses.length), + createdAt: now, + updatedAt: now, + }); + } + return out.sort((x, y) => + y.confidence - x.confidence || + x.manufacturer.localeCompare(y.manufacturer) || + x.pidOrDid.localeCompare(y.pidOrDid)); +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Motor — on-demand (kalıcı depo/wiring YOK) + * ════════════════════════════════════════════════════════════════════════ */ + +export class VehicleLearningEngine { + private readonly _readRecords: () => VehicleKnowledgeRecord[]; + private readonly _readManufacturers: () => ManufacturerKnowledge[]; + private readonly _now: () => number; + + constructor( + readRecords: () => VehicleKnowledgeRecord[] = () => vehicleKnowledgeBaseStore.list(), + readManufacturers: () => ManufacturerKnowledge[] = () => getManufacturerIntelligence(), + now: () => number = () => Date.now(), + ) { + this._readRecords = readRecords; + this._readManufacturers = readManufacturers; + this._now = now; + } + + /** VKB + Manufacturer Intelligence'tan öğrenme kanıtlarını üretir (on-demand). FAIL-SOFT. */ + computeEvidence(): LearningEvidence[] { + try { + const hints: Record = {}; + for (const m of this._readManufacturers()) { + if (m && m.manufacturer && m.profileHint) hints[m.manufacturer.toLowerCase()] = m.profileHint; + } + return buildEvidenceFromRecords(this._readRecords(), { now: this._now(), manufacturerProfileHints: hints }); + } catch { + return []; // fail-soft + } + } + + /** Yalnız 'strong' kanıtlar. */ + getStrong(): LearningEvidence[] { + return this.computeEvidence().filter((e) => e.status === 'strong'); + } + + /** Yalnız 'candidate' kanıtlar. */ + getCandidates(): LearningEvidence[] { + return this.computeEvidence().filter((e) => e.status === 'candidate'); + } + + /** Belirli bir markanın kanıtları. */ + getByManufacturer(name: string): LearningEvidence[] { + const key = (name || '').trim().toLowerCase(); + return this.computeEvidence().filter((e) => e.manufacturer.toLowerCase() === key); + } +} + +/** Uygulama geneli tekil motor (on-demand — UI/mantık çağırınca üretir; wiring YOK). */ +export const vehicleLearningEngine = new VehicleLearningEngine();