Files
ScarriffleandClaude Opus 5 3da673662f Zwischenablage, Notizen und der Gesichtsvergleich
Drei neue Module nach dem Bauplan von `ShelfProvider`: reine Regeln getrennt vom
Zustand, versionierte Indexdatei, Inhalte in Dateien statt in `UserDefaults` —
was dort steht, liest jedes Programm mit `defaults read` mit.

Die Zwischenablage achtet `ConcealedType`, `TransientType` und
`AutoGeneratedType`. Diese Marken sind eine Verabredung zwischen Programmen und
kein Systemdienst; wer sie missachtet, sammelt fremde Passwörter ein.

Der Gesichtsvergleich stand im Plan als der eine echte Unsicherheitsfaktor. Ein
Spike hat ihn erledigt: `VNGenerateFaceprintRequest` gibt es nicht, aber
`VNGenerateImageFeaturePrintRequest` ist öffentlich, liefert 768 Elemente und
ein Abstandsmaß — Faktor 3,5 Trennschärfe. Kein eigenes CoreML-Modell nötig.

`FaceMatcher` entscheidet über mehrere Posen, mehrere Einzelbilder und eine
Sperre nach Fehlversuchen. Die Schwelle liegt bewusst nicht mittig: ein falsches
Ja ist ein Fremder am entsperrten Rechner, ein falsches Nein nur ein Passwort,
das man ohnehin kennt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:06:06 +02:00

127 lines
4.9 KiB
Swift

import Testing
import Foundation
@testable import FaceProvider
@Suite("Gesichtsvergleich")
struct FaceMatcherTests {
/// Ein Vektor, der sich um `offset` von der Grundform unterscheidet.
private func vector(_ offset: Float, count: Int = 768) -> [Float] {
(0..<count).map { Float($0 % 7) * 0.01 + offset }
}
private func matcher(offsets: [Float] = [0],
confirmations: Int = 3,
attempts: Int = 10) -> FaceMatcher {
FaceMatcher(templates: offsets.enumerated().map {
FaceTemplate(pose: "p\($0.offset)", vector: vector($0.element))
},
requiredConfirmations: confirmations,
maximumAttempts: attempts)
}
// MARK: - Abstand
@Test("Derselbe Vektor hat Abstand null")
func identicalIsZero() {
#expect(FaceMatcher.distance(vector(0), vector(0)) == 0)
}
@Test("Verschieden lange Vektoren ergeben keinen Abstand")
func lengthMismatchIsNil() {
// Zwei verschieden lange Vektoren zu vergleichen ergäbe eine Zahl ohne
// Bedeutung — und die sähe aus wie ein sehr guter Treffer.
#expect(FaceMatcher.distance([1, 2, 3], [1, 2]) == nil)
#expect(FaceMatcher.distance([], []) == nil)
}
@Test("Der kleinste Abstand über alle Posen zählt")
func closestPoseWins() {
// Die Posen sind absichtlich verschieden; ein Mittelwert bestrafte
// genau die Vielfalt, für die sie aufgenommen wurden.
let m = matcher(offsets: [0, 0.5])
let d = m.distance(to: vector(0.5))
#expect(d == 0)
}
// MARK: - Entscheidung
@Test("Erst nach genug Bestätigungen wird geöffnet")
func needsSeveralConfirmations() {
// Die Kamera liefert dreißig Bilder je Sekunde — unter dreißig ist
// irgendwann eines dabei, das zufällig passt.
let m = matcher(confirmations: 3)
var session = FaceMatcher.Session()
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 1))
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 2))
#expect(m.evaluate(vector(0), in: &session) == .accepted)
}
@Test("Ein fremdes Gesicht wird abgelehnt")
func strangerIsRejected() {
let m = matcher()
var session = FaceMatcher.Session()
#expect(m.evaluate(vector(5), in: &session) == .undecided(confirmations: 0))
#expect(session.attempts == 1)
}
@Test("Ein Fehlversuch setzt die Bestätigungen zurück")
func failureResetsConfirmations() {
// Sonst summierten sich zufällige Treffer über Minuten zu einem Ja.
let m = matcher(confirmations: 3)
var session = FaceMatcher.Session()
_ = m.evaluate(vector(0), in: &session)
_ = m.evaluate(vector(0), in: &session)
#expect(session.confirmations == 2)
_ = m.evaluate(vector(5), in: &session)
#expect(session.confirmations == 0)
}
@Test("Nach genug Fehlversuchen ist gesperrt")
func locksOutAfterAttempts() {
let m = matcher(attempts: 3)
var session = FaceMatcher.Session()
_ = m.evaluate(vector(5), in: &session)
_ = m.evaluate(vector(5), in: &session)
#expect(m.evaluate(vector(5), in: &session) == .lockedOut)
}
@Test("Nach der Sperre hilft auch das richtige Gesicht nicht mehr")
func lockoutIsFinal() {
// Ab hier bleibt nur das Passwort — sonst wäre die Sperre eine
// Verzögerung und keine Grenze.
let m = matcher(attempts: 2)
var session = FaceMatcher.Session()
_ = m.evaluate(vector(5), in: &session)
_ = m.evaluate(vector(5), in: &session)
#expect(m.evaluate(vector(0), in: &session) == .lockedOut)
}
@Test("Ohne hinterlegtes Gesicht öffnet nichts")
func noEnrollmentNeverOpens() {
// Ein leerer Vergleich darf kein Freifahrtschein sein.
let m = FaceMatcher(templates: [])
var session = FaceMatcher.Session()
#expect(!m.isEnrolled)
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 0))
#expect(session.attempts == 1)
}
@Test("Ein Vektor falscher Länge gilt als Fehlversuch, nicht als Treffer")
func wrongLengthIsAFailure() {
let m = matcher()
var session = FaceMatcher.Session()
#expect(m.evaluate([1, 2, 3], in: &session) == .undecided(confirmations: 0))
#expect(session.attempts == 1)
}
@Test("Die Schwelle liegt näher am Ähnlichen als am Verschiedenen")
func thresholdLeansStrict() {
// Aus dem Spike: ähnlich 0,13 — verschieden 0,47. Ein falsches Ja ist
// ein Fremder am entsperrten Rechner, ein falsches Nein nur ein
// Passwort, das man ohnehin kennt.
#expect(FaceMatcher.defaultThreshold < (0.13 + 0.47) / 2)
#expect(FaceMatcher.defaultThreshold > 0.13)
}
}