Face Comparison: data model, verdict & threshold model, videochat vs self-service

Source-verified reference for how the vuer_oss faceComparisons table is written, linked, threshold-resolved, and verdict-classified — including the videochat (operator) vs self-service-v2 split that a self-service-only view misses. Produced during FKITDEV-8827 design work; cite-checked against the local vuer_oss worktree on branch customization/raiffeisen.

For Agents

This is the canonical model note for face comparison. Companion to face-comparison-different-face-db-query (the read-only SQL recipe for one customer). This note supersedes/corrects two implicit assumptions in that earlier note: (1) PION is a videochat flow, not self-service-v2; (2) operator/videochat comparisons are not verdict-classified at runtime — only the operator room view shows raw distance. All paths below are file:line in vuer_oss.

TL;DR

  • A faceComparisons row is keyed by EITHER roomId (videochat/operator) OR selfServiceRoomId (self-service-v2). No DB constraint enforces mutual exclusivity.
  • euclideanDistance (FLOAT, nullable) is misnamed — it holds cosine distance, range 0–2.
  • The verdict is derived at read time, never stored. status is only {created, failed, success}.
  • Threshold sourcing differs by link type: self-service rooms can override per-room; videochat/operator rooms have no per-room config path and aren’t classified at runtime at all.

1. Two comparison linkages

faceComparisons rows attach to a flow via one of two FK columns:

LinkColumnProducing flow
Videochat / operatorroomIdoperator-assisted videochat; written by the videochat:close hook
Self-service v2selfServiceRoomIdmobile self-service-v2

No mutual-exclusivity constraint

Nothing in the schema forbids both FKs being set, or both being null (orphan). A general export must classify linkType defensively: roomId && !selfServiceRoomId → videochat; selfServiceRoomId && !roomId → self-service; neither → orphan; both → treat as videochat with a logged warning.

Modelserver/db/model/faceComparison.js, registered in server/db/models.js:298-309:

  • status — STRING enum ['created', 'failed', 'success'], default 'created'.
  • euclideanDistance — FLOAT, nullable, = cosine distance (0–2) despite the legacy name.
  • FKs: roomId, selfServiceRoomId, customerId, userId, recognitionFromId, recognitionToId (last two → faceRecognitions).
  • Scope withRecognitions (models.js:298-309) joins recognitionFrom / recognitionTo (belongsTo, not required — so it stays limit/offset-safe for chunked pagination).
  • No flowId / taskId / step column — portrait-vs-liveness-vs-document is only inferrable from the joined FaceRecognition.imageCategory (a heuristic, config-dependent).

2. PION is a videochat flow, NOT self-service

This is the load-bearing correction for FKITDEV-8827 (Raiffeisen PION face-comparison export).

  • The customization/flow/pion-online-verification-phase-1/2 protos declare videochat and set compareFaceWith nowhere.
  • Therefore PION comparisons are not produced by the self-service-v2 liveness path. They are produced by the videochat:close hook (server/faceRecognition/faceRecognitionHooks.js:26-44), which compares the configured getComparisonPair('default') categories and writes a row keyed by roomId.
  • That hook is gated by deployment config faceRecognition.comparisonPairs (server/service/FaceRecognitionService.js:7,20) — not visible in the repo.

A self-service-only query returns ZERO PION rows

Filtering faceComparisons on selfServiceRoomId (the obvious “mobile” approach) will return nothing for PION. PION rows live under roomId. Whether any PION rows exist depends on the deployment’s faceRecognition.comparisonPairs.default — confirm with the deployment / ticket owner before promising data; an empty result is a valid, honest answer.

3. Comparison call sites (4) — gating differs

Three of the four sites are gated by task.options.recognitionOptions.compareFaceWith; the videochat hook is gated by deployment config instead.

#StepLocationGate
1Liveness v2server/service/SelfServiceV2Service.js:1418if (recognitionOptions.compareFaceWith) — base V2 proto does not set it
2Portrait / ID-documentserver/flow/FlowService.js:2899-2943gated at :2902 by compareFaceWith
3Videochat-close hookserver/faceRecognition/faceRecognitionHooks.js:26-44config faceRecognition.comparisonPairs; writes roomId
4Self-service v1via the same compareFaceDetections

For Agents

Note path #2 is server/flow/FlowService.js, not server/service/. This was a recurring stale fact in the triage agents’ _shared-context.md.

4. Verdict is DERIVED, not stored

status is {created, failed, success}. The human-meaningful verdict (different_face etc.) is computed at read time by SelfServiceCheckerService.getFaceComparisonResult (server/service/SelfServiceCheckerService.js:132-154), which returns 3 outcomes (operators are <=):

conditionoutcome
distance <= perfectCHECK_SUCCESS
distance <= probableCHECK_PROBABLE (the match tier collapses here because the default match: null)
distance > probableCHECK_FAILURE (i.e. different_face)

Code-default thresholds (:32-48): { perfect: 0.5, match: null, probable: 0.6 }. So by default different_face ≈ distance > 0.6 — but a room may override this.

Videochat/operator rows are NOT verdict-classified at runtime

getFaceComparisonResult is a self-service path. For operator/videochat comparisons there is no runtime verdict — server/web/routes/room.endpoint.js:144 shows the raw distance only. Any verdict applied to a roomId-linked row (e.g. in an export) is a report-computed classification, not what the system used. Always carry the raw distance + the perfect/probable actually applied so the call is auditable.

Link typeThreshold sourceMerge semantics
Self-serviceper-room selfService:v2:config:state activity, falling back to globalREPLACES, not merges (see caveat)
Videochat / operatorglobal only (no per-room config path)
  • Per-room (self-service): read via getActivityLog (server/db/helpers.js:13-33) — Activity filtered by type + selfServiceRoomId, ordered ASC, take the oldest entry [0]. Pull content.settings.faceComparison.euclideanDistances. Matches SelfServiceCheckerService:122-130.
  • Global: Setting table, key faceComparison, value.euclideanDistances. SettingsService.init() (server/service/SettingsService.js) persists the code defaults into the Setting table at startup, so the global row normally exists even with no manual config.
  • Code defaults: { perfect: 0.5, match: null, probable: 0.6 }.

REPLACE vs MERGE — read carefully

The runtime self-service path replaces the threshold set with the room’s config (it does not merge per-key onto the global). A partial room config therefore does not inherit missing keys from the global at runtime. (Note: the FKITDEV-8827 export design deliberately chooses to merge partial room config onto defaults so partial overrides keep inherited keys — that’s a tool-level decision, not a mirror of runtime. Don’t conflate the two.)

6. No createdAt index

faceComparisons has only FK indexes — there is no index on createdAt. Any date-range export (the whole point of FKITDEV-8827) risks a full table scan. For stable pagination, order by id (PK), not createdAt (non-unique, unindexed).

Recommended core change (deferred)

Add a createdAt index on faceComparisons for large deployments. This is a devel core change, intentionally out of scope for the Raiffeisen customization bin.

7. Report bin pattern — and a real latent bug

The house pattern for an on-demand report bin:

bin → ReportsService subclass → _exportReport(aoaRows, null, null, format, null) → Buffer → fs.writeFile

Note this is the direct Buffer + fs.writeFile path, NOT the _prepareDownload / Download-model / storage path.

Latent bug in the mirrored template

customization/bin/raiffeisen-selfservice-failed-reports.js:106 calls prepareReportData, which NrtFailureReportService never implements — it’s abstract in ReportsService:297. It only avoids throwing because its cron is gated active: false (customization/cron/FailedSelfserviceRoomsReport.js:73), so the broken path is never reached. Treat this as a latent bug: any new report service copied from this template must implement the data-prep method it calls (do not copy the bug).

8. DB dialect — Postgres now, MySQL supported

  • The dev DB is Postgres (config/dev.json).
  • BUT MySQL is a supported dialect — models guard with sequelize.dialect.name !== 'mysql'.

Prefer portable SQL in customization queries

Use portable LOWER(col) LIKE LOWER(:pattern) instead of Postgres-only ILIKE in customization queries (e.g. flow-name prefix filters), so the code survives a MySQL deployment.

9. Design decision — FKITDEV-8827 general both-paths export

FKITDEV-8827 builds a general, both-paths face-comparison export bin (not self-service-only, precisely because PION is videochat):

  • customization/bin/raiffeisen-facecomparison-export.js — CLI: args, bootstrap, call service, write 0o600 file, exit.
  • customization/service/ReportServices/FaceComparisonExportService.js — extends server/service/ReportsService; query (both link types, no link-type filter, order by id, 1000/page) + threshold resolution + report-computed verdict + row build; returns { report, rows }.
  • Unit test mirrors RaiffeisenNrtFailureReportService.test.js.
  • CLI options include --flow <prefix> (e.g. pion) → Flow.findAll collecting roomId/selfServiceRoomId, then Op.or on both keys; -n/--withname decrypts customer name (PII, off by default; needs Customer.data + key loaded or every name falls back to #<id>).
  • Verdict honesty: for videochat rows the verdict is report-computed (no runtime equivalent); every row carries raw distance + applied thresholdPerfect/thresholdProbable.

Full spec (external, in the worktree, not the vault): /Users/levander/coding/facekom/.worktrees/vuer_oss-FKITDEV-8827/docs/superpowers/specs/2026-06-03-raiffeisen-facecomparison-export-design.md

  • FKITDEV-8827 — Raiffeisen PION face-comparison export (parent ASSRAFIPI-119); the design this note grounds
  • face-comparison-different-face-db-query — companion read-only SQL recipe (customer “P”); see corrections noted above re PION = videochat and operator rows not being verdict-classified
  • vuer_oss — host service: faceComparisons / faceRecognitions, FaceRecognitionService, SelfServiceV2Service, FlowService, SelfServiceCheckerService, SettingsService, ReportsService
  • vuer_cv — computer-vision service producing the recognitions compared here
  • database-schema — Postgres schema reference
  • FaceKom — platform overview