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>
127 lines
4.9 KiB
Swift
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)
|
|
}
|
|
}
|